Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TEC email reminders feature #536

Merged
merged 6 commits into from
Nov 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion backend/src/API/mailAPI.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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<Promise<void>[]> => {
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<AxiosResponse> => {
const subject = 'TEC Reminder';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are creating variables with promises, I'd await them first instead of inlining the await, otherwise you might as well just inline the value and delete the variable entirely.

So instead of

  const allEvents = getAllTeamEvents(req.body);
  const futureEvents = (await allEvents).filter((event) => {

try

  const allEvents = await getAllTeamEvents(req.body);
  const futureEvents = allEvents.filter((event) => {

Same with memberEvents, although I'd personally rename this to memberEventAttendance

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);
};
31 changes: 30 additions & 1 deletion backend/src/API/teamEventsAPI.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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<unknown> => {
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;
};
8 changes: 6 additions & 2 deletions backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ import {
requestTeamEventCredit,
getTeamEventAttendanceByUser,
updateTeamEventAttendance,
deleteTeamEventAttendance
deleteTeamEventAttendance,
notifyMember
} from './API/teamEventsAPI';
import {
getAllCandidateDeciderInstances,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) => ({
Expand Down
4 changes: 4 additions & 0 deletions backend/src/utils/permissionsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export default class PermissionsManager {
return this.isLeadOrAdmin(mem);
}

static async canNotifyMembers(mem: IdolMember): Promise<boolean> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

return this.isLeadOrAdmin(mem);
}

static async canDeploySite(mem: IdolMember): Promise<boolean> {
return this.isLeadOrAdmin(mem);
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/API/MembersAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ export class MembersAPI {
(res) => res.data.isIDOLMember
);
}

public static notifyMember(member: Member): Promise<MemberResponseObj> {
return APIWrapper.post(`${backendURL}/team-event-reminder`, member).then((res) => res.data);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,13 @@
position: sticky;
left: 0;
}

.remindButton {
position: sticky;
left: 35%;
}

.notify {
position: absolute;
right: 1%;
}
36 changes: 33 additions & 3 deletions frontend/src/components/Admin/TeamEvent/TeamEventDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -57,9 +58,31 @@ const TeamEventDashboard: React.FC = () => {
Team Event Dashboard
</Header>
<div className={styles.tableContainer}>
<Table celled selectable striped classname={styles.dashboardTable}>
<Table celled selectable striped className={styles.dashboardTable}>
<Table.Header>
<Table.HeaderCell className={styles.nameCell}>Name</Table.HeaderCell>
<Table.HeaderCell className={styles.nameCell}>
Name
<NotifyMemberModal
all={true}
trigger={
<Button className={styles.remindButton} size="small" color="red">
Remind All
</Button>
}
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)
);
})}
/>
</Table.HeaderCell>
<Table.HeaderCell>Total</Table.HeaderCell>
{COMMUNITY_EVENTS && <Table.HeaderCell>Total Community Credits</Table.HeaderCell>}
{teamEvents.map((event) => (
Expand All @@ -85,6 +108,13 @@ const TeamEventDashboard: React.FC = () => {
<Table.Row>
<Table.Cell positive={totalCreditsMet} className={styles.nameCell}>
{member.firstName} {member.lastName} ({member.netid})
{!totalCreditsMet && (
<NotifyMemberModal
all={false}
trigger={<Icon className={styles.notify} name="exclamation" color="red" />}
member={member}
/>
)}
</Table.Cell>
<Table.Cell positive={totalCreditsMet}>{totalCredits}</Table.Cell>
{COMMUNITY_EVENTS && (
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/Modals/NotifyMemberModal.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.buttonsWrapper {
display: flex;
justify-content: flex-end;
padding-top: 15px;
}
69 changes: 69 additions & 0 deletions frontend/src/components/Modals/NotifyMemberModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}
open={open}
trigger={trigger}
>
<Modal.Header> Are you sure you want to notify {subject}?</Modal.Header>
<Modal.Content>
This will send an email to {subject} reminding them that they do not have enough TEC Credits
completed yet this semester.
<Form>
<div className={styles.buttonsWrapper}>
<Form.Button onClick={() => setOpen(false)}>Cancel</Form.Button>
<Form.Button
content="Yes"
labelPosition="right"
icon="checkmark"
onClick={() => {
handleSubmit();
}}
positive
/>
</div>
</Form>
</Modal.Content>
</Modal>
);
};
export default NotifyMemberModal;
Loading