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 2 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
24 changes: 23 additions & 1 deletion backend/src/API/mailAPI.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { Request } from 'express';
import getEmailTransporter from '../nodemailer';
import { isProd } from '../api';
Expand Down Expand Up @@ -50,9 +50,31 @@ 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 text =
'Hey! You currently do not have enough team events credits this semester. This is a reminder to get at least 3 team events credits by the end of the semester.';
return emailMember(req, member, subject, text);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we make the credit amount custom based on the role type? Leads need 6 credits, all other members need only 3.

};
20 changes: 20 additions & 0 deletions backend/src/API/memberAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PermissionsManager from '../utils/permissionsManager';
import { BadRequestError, PermissionError } from '../utils/errors';
import { bucket } from '../firebase';
import { getNetIDFromEmail, computeMembersDiff } from '../utils/memberUtil';
import { sendTECReminder } from './mailAPI';

const membersDao = new MembersDao();

Expand Down Expand Up @@ -74,6 +75,25 @@ export const deleteMember = async (email: string, user: IdolMember): Promise<voi
);
};

export const notifyMember = async (
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems to be TeamEvent specific. Consider putting this in teamEventAPI instead?

req: Request,
body: 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 (!body.email || body.email === '') {
throw new BadRequestError("Couldn't notify member with undefined email!");
}

sendTECReminder(req, body);
return body;
};

export const deleteImage = async (email: string): Promise<void> => {
// Create a reference to the file to delete
const netId: string = getNetIDFromEmail(email);
Expand Down
6 changes: 5 additions & 1 deletion backend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
setMember,
deleteMember,
updateMember,
notifyMember,
getUserInformationDifference,
reviewUserInformationChange
} from './API/memberAPI';
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 @@ -219,6 +220,9 @@ loginCheckedDelete('/member/:email', async (req, user) => {
loginCheckedPut('/member', async (req, user) => ({
member: await updateMember(req, req.body, user)
}));
loginCheckedPost('/notifyMember', async (req, user) => ({
Copy link
Collaborator

Choose a reason for hiding this comment

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

/team-event-reminder?

member: await notifyMember(req, req.body, user)
}));

loginCheckedGet('/memberDiffs', async (_, user) => ({
diffs: await getUserInformationDifference(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}/notifyMember`, 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.map((member) => {

Check warning on line 28 in frontend/src/components/Modals/NotifyMemberModal.tsx

View workflow job for this annotation

GitHub Actions / check

Array.prototype.map() expects a return value from arrow function
Copy link
Collaborator

Choose a reason for hiding this comment

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

You could just do a forEach here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

make sure to also await the MembersAPI.notifyMember(member);

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