Skip to content

Commit 03d9a76

Browse files
authored
Merge pull request #2171 from Northeastern-Electric-Racing/#795-slack-ping-on-task-deadline
#795 slack ping on task deadline
2 parents 88b4779 + 3c69597 commit 03d9a76

10 files changed

Lines changed: 225 additions & 6 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: Send Task Deadline Notifications
2+
3+
on:
4+
schedule:
5+
- cron: '0 9 * * *'
6+
7+
jobs:
8+
send_notifications:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Send notifications
12+
run: |
13+
curl -X POST https://api.finishlinebyner.com/notifications/task-deadlines \
14+
-H 'Authorization: ${{ secrets.NOTIFICATION_ENDPOINT_SECRET }}'

src/backend/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import descriptionBulletsRouter from './src/routes/description-bullets.routes';
1212
import tasksRouter from './src/routes/tasks.routes';
1313
import reimbursementRequestsRouter from './src/routes/reimbursement-requests.routes';
1414
import designReviewRouter from './src/routes/design-review.routes';
15+
import notificationsRouter from './src/routes/notifications.routes';
1516

1617
const app = express();
1718
const port = process.env.PORT || 3001;
@@ -54,6 +55,7 @@ app.use('/description-bullets', descriptionBulletsRouter);
5455
app.use('/tasks', tasksRouter);
5556
app.use('/reimbursement-requests', reimbursementRequestsRouter);
5657
app.use('/design-reviews', designReviewRouter);
58+
app.use('/notifications', notificationsRouter);
5759
app.use('/', (_req, res) => {
5860
res.json('Welcome to FinishLine');
5961
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NextFunction, Request, Response } from 'express';
2+
import NotificationsService from '../services/notifications.services';
3+
4+
export default class NotificationsController {
5+
static async sendTaskDeadlineSlackNotifications(_req: Request, res: Response, next: NextFunction) {
6+
try {
7+
await NotificationsService.sendTaskDeadlineSlackNotifications();
8+
9+
res.status(200).json({ message: 'Successfully sent task deadline notifications!' });
10+
} catch (error: unknown) {
11+
next(error);
12+
}
13+
}
14+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import express from 'express';
2+
import NotificationsController from '../controllers/notifications.controllers';
3+
4+
const notificationsRouter = express.Router();
5+
6+
notificationsRouter.post('/task-deadlines', NotificationsController.sendTaskDeadlineSlackNotifications);
7+
8+
export default notificationsRouter;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import prisma from '../prisma/prisma';
2+
import {
3+
TaskWithAssignees,
4+
endOfDayTomorrow,
5+
getTeamFromTaskAssignees,
6+
startOfDayTomorrow,
7+
usersToSlackPings
8+
} from '../utils/notifications.utils';
9+
import { sendMessage } from '../integrations/slack';
10+
11+
export default class NotificationsService {
12+
/**
13+
* Sends the task deadline slack notifications for all tasks with a deadline of tomorrow
14+
*/
15+
static async sendTaskDeadlineSlackNotifications() {
16+
const startOfDay = startOfDayTomorrow();
17+
const endOfDay = endOfDayTomorrow();
18+
19+
const tasks = await prisma.task.findMany({
20+
where: {
21+
deadline: {
22+
gte: startOfDay,
23+
lt: endOfDay
24+
},
25+
status: {
26+
not: 'DONE'
27+
}
28+
},
29+
include: {
30+
assignees: {
31+
include: {
32+
userSettings: true,
33+
teamAsHead: true,
34+
teamsAsLead: true,
35+
teamsAsMember: true
36+
}
37+
},
38+
wbsElement: {
39+
include: {
40+
project: { include: { teams: true } }
41+
}
42+
}
43+
}
44+
});
45+
46+
const teamTaskMap = new Map<string, TaskWithAssignees[]>();
47+
48+
// group tasks due by team in a map
49+
tasks.forEach((task) => {
50+
const teamSlackId = getTeamFromTaskAssignees(task.assignees).slackId;
51+
52+
const currentTasks = teamTaskMap.get(teamSlackId);
53+
if (currentTasks) {
54+
currentTasks.push(task);
55+
teamTaskMap.set(teamSlackId, currentTasks);
56+
} else {
57+
teamTaskMap.set(teamSlackId, [task]);
58+
}
59+
});
60+
61+
// send the notifications to each team for their respective tasks
62+
teamTaskMap.forEach((tasks, slackId) => {
63+
const messageBlock = tasks
64+
.map(
65+
(task) =>
66+
`${usersToSlackPings(task.assignees ?? [])} ${task.title} due tomorrow in project ${task.wbsElement?.name}`
67+
)
68+
.join('\n\n');
69+
70+
// messageBlock will be empty if there are tasks with no assignees
71+
if (messageBlock !== '')
72+
sendMessage(slackId, ':sparkles: :pepe-coop: UPCOMING TASK DEADLINES :pepe-coop: :sparkles: \n\n\n' + messageBlock);
73+
});
74+
}
75+
}

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ import {
4949
vendorTransformer
5050
} from '../transformers/reimbursement-requests.transformer';
5151
import reimbursementQueryArgs from '../prisma-query-args/reimbursement.query-args';
52-
import { UserWithSettings } from '../utils/auth.utils';
52+
import { UserWithSecureSettings } from '../utils/auth.utils';
5353
import { sendReimbursementRequestDeniedNotification } from '../utils/slack.utils';
5454

5555
export default class ReimbursementRequestService {
@@ -111,7 +111,7 @@ export default class ReimbursementRequestService {
111111
* @returns the created reimbursement request
112112
*/
113113
static async createReimbursementRequest(
114-
recipient: UserWithSettings,
114+
recipient: UserWithSecureSettings,
115115
dateOfExpense: Date,
116116
vendorId: string,
117117
account: ClubAccount,

src/backend/src/services/tasks.services.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import prisma from '../prisma/prisma';
77
import taskTransformer from '../transformers/tasks.transformer';
88
import { NotFoundException, AccessDeniedException, HttpException, DeletedException } from '../utils/errors.utils';
99
import { hasPermissionToEditTask, sendSlackTaskAssignedNotificationToUsers } from '../utils/tasks.utils';
10-
import { areUsersPartOfTeams, isUserOnTeam } from '../utils/teams.utils';
10+
import { allUsersOnTeam, areUsersPartOfTeams, isUserOnTeam } from '../utils/teams.utils';
1111
import { getUsers } from '../utils/users.utils';
1212
import { wbsNumOf } from '../utils/utils';
1313

@@ -80,6 +80,9 @@ export default class TasksService {
8080
if (!areUsersPartOfTeams(teams, users))
8181
throw new HttpException(400, `All assignees must be part of one of the project's team!`);
8282

83+
if (!teams.some((team) => allUsersOnTeam(team, users)))
84+
throw new HttpException(400, 'All assignees must be part of the same team!');
85+
8386
if (!isUnderWordCount(title, 15)) throw new HttpException(400, 'Title must be less than 15 words');
8487
if (!isUnderWordCount(notes, 250)) throw new HttpException(400, 'Notes must be less than 250 words');
8588

@@ -202,6 +205,9 @@ export default class TasksService {
202205
throw new HttpException(400, "All assignees must be part of one of the project's teams");
203206
}
204207

208+
if (!teams.some((team) => allUsersOnTeam(team, assigneeUsers)))
209+
throw new HttpException(400, 'All assignees must be part of the same team!');
210+
205211
// retrieve userId for every assignee to update task's assignees in the database
206212
const transformedAssigneeUsers = assigneeUsers.map((user) => {
207213
return {

src/backend/src/utils/auth.utils.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken';
22
import { Request, Response, NextFunction } from 'express';
33
import { JwtPayload, VerifyErrors } from 'jsonwebtoken';
44
import prisma from '../prisma/prisma';
5-
import { NotFoundException } from './errors.utils';
5+
import { HttpException, NotFoundException } from './errors.utils';
66
import { User, User_Secure_Settings, User_Settings } from '@prisma/client';
77

88
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'i<3security';
@@ -32,6 +32,10 @@ export const requireJwtProd = (req: Request, res: Response, next: NextFunction)
3232
req.method === 'OPTIONS' // this is a pre-flight request and those don't send cookies
3333
) {
3434
next();
35+
} else if (
36+
req.path.startsWith('/deadline-notifications') // task deadline notification endpoint
37+
) {
38+
notificationEndpointAuth(req, res, next);
3539
} else {
3640
const { token } = req.cookies;
3741

@@ -59,6 +63,10 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) =
5963
req.path === '/users' // dev login needs the list of users to log in
6064
) {
6165
next();
66+
} else if (
67+
req.path.startsWith('/deadline-notifications') // task deadline notification endpoint
68+
) {
69+
notificationEndpointAuth(req, res, next);
6270
} else {
6371
const devUserId = req.headers.authorization;
6472

@@ -70,6 +78,20 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) =
7078
}
7179
};
7280

81+
const notificationEndpointAuth = (req: Request, res: Response, next: NextFunction) => {
82+
const { authorization } = req.headers;
83+
const { NOTIFICATION_ENDPOINT_SECRET } = process.env;
84+
85+
if (!NOTIFICATION_ENDPOINT_SECRET) throw new HttpException(500, 'Notification endpoint secret not found!');
86+
87+
if (!authorization) return res.status(401).json({ message: 'Authentication Failed: Secret not found!' });
88+
89+
if (authorization !== NOTIFICATION_ENDPOINT_SECRET)
90+
return res.status(401).json({ message: 'Authentication Failed: Invalid secret!' });
91+
92+
next();
93+
};
94+
7395
/**
7496
* get the user making the request.
7597
* @param res - we use the response because that's where we stored the userId data during jwt validation
@@ -85,6 +107,9 @@ export const getCurrentUser = async (res: Response): Promise<User> => {
85107

86108
export type UserWithSettings = User & {
87109
userSettings: User_Settings | null;
110+
};
111+
112+
export type UserWithSecureSettings = UserWithSettings & {
88113
userSecureSettings: User_Secure_Settings | null;
89114
};
90115

@@ -94,7 +119,7 @@ export type UserWithSettings = User & {
94119
* @returns the user with their user settings
95120
* @throws if no user with the userId exists
96121
*/
97-
export const getCurrentUserWithUserSettings = async (res: Response): Promise<UserWithSettings> => {
122+
export const getCurrentUserWithUserSettings = async (res: Response): Promise<UserWithSecureSettings> => {
98123
const { userId } = res.locals;
99124
const user = await prisma.user.findUnique({
100125
where: { userId },
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Team, Task as Prisma_Task, WBS_Element } from '@prisma/client';
2+
import { UserWithSettings } from './auth.utils';
3+
import { HttpException } from './errors.utils';
4+
import { UserWithTeams, getTeamsFromUsers } from './teams.utils';
5+
6+
export type TaskWithAssignees = Prisma_Task & {
7+
assignees: UserWithSettings[] | null;
8+
wbsElement: WBS_Element | null;
9+
};
10+
11+
export const usersToSlackPings = (users: UserWithSettings[]) => {
12+
// https://api.slack.com/reference/surfaces/formatting#mentioning-users
13+
return users.map((user) => `<@${user.userSettings?.slackId}>`).join(' ');
14+
};
15+
16+
/**
17+
* Gets the team of a task's assignees.
18+
* Assumes all assigness share a team
19+
* @param users the users of the task
20+
* @returns the team assigned to the task
21+
*/
22+
export const getTeamFromTaskAssignees = (users: UserWithTeams[]): Team => {
23+
const usersTeams = getTeamsFromUsers(users);
24+
25+
firstUsersTeams: for (const team of usersTeams[0]) {
26+
for (let i = 1; i < usersTeams.length; i++) {
27+
// If the team is not found in the current user's teams, continue to the next team
28+
if (!usersTeams[i].some((t) => t.teamId === team.teamId)) continue firstUsersTeams;
29+
}
30+
return team;
31+
}
32+
33+
throw new HttpException(400, 'All of the users do not share a team!');
34+
};
35+
36+
/**
37+
* Gets the beginning of the day tomorrow
38+
* @returns the beginning of the day tomorrow (at 12am)
39+
*/
40+
export const startOfDayTomorrow = () => {
41+
return new Date(new Date().setHours(24, 0, 0, 0));
42+
};
43+
44+
/**
45+
* Gets the end of the day tomorrow
46+
* @returns the end of the day tomorrow (i.e. 12am of the following day)
47+
*/
48+
export const endOfDayTomorrow = () => {
49+
const startOfDay = startOfDayTomorrow();
50+
const endOfDay = new Date(startOfDay);
51+
endOfDay.setDate(startOfDay.getDate() + 1);
52+
return endOfDay;
53+
};

src/backend/src/utils/teams.utils.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Prisma, User } from '@prisma/client';
1+
import { Prisma, User, Team } from '@prisma/client';
2+
import { UserWithSettings } from './auth.utils';
23

34
const teamQueryArgsMembersOnly = Prisma.validator<Prisma.TeamArgs>()({
45
include: {
@@ -50,3 +51,24 @@ export const areUsersPartOfTeams = (teams: Prisma.TeamGetPayload<typeof teamQuer
5051
export const isUserPartOfTeams = (teams: Prisma.TeamGetPayload<typeof teamQueryArgsMembersOnly>[], user: User) => {
5152
return teams.some((team) => isUserOnTeam(team, user));
5253
};
54+
55+
export type UserWithTeams = UserWithSettings & {
56+
teamAsHead: Team | null;
57+
teamsAsLead: Team[] | null;
58+
teamsAsMember: Team[] | null;
59+
};
60+
61+
/**
62+
* Gets the teams from a list of users
63+
* @param users the users to get the teams from
64+
* @returns an array of the teams each user is in
65+
*/
66+
export const getTeamsFromUsers = (users: UserWithTeams[]): Team[][] => {
67+
return users.map((user) => {
68+
const teams = [];
69+
if (user.teamAsHead) teams.push(user.teamAsHead);
70+
if (user.teamsAsLead) teams.push(...user.teamsAsLead);
71+
if (user.teamsAsMember) teams.push(...user.teamsAsMember);
72+
return teams;
73+
});
74+
};

0 commit comments

Comments
 (0)