Skip to content

Commit d89a5f4

Browse files
authored
Merge pull request #4056 from Northeastern-Electric-Racing/#4055-attendance
attendance by team
2 parents df34297 + f41cfd9 commit d89a5f4

23 files changed

Lines changed: 1319 additions & 4 deletions

File tree

src/backend/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import partsRouter from './src/routes/parts.routes.js';
2727
import financeRouter from './src/routes/finance.routes.js';
2828
import calendarRouter from './src/routes/calendar.routes.js';
2929
import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js';
30+
import attendanceRouter from './src/routes/attendance.routes.js';
3031

3132
const app = express();
3233

@@ -112,6 +113,7 @@ app.use('/parts', partsRouter);
112113
app.use('/finance', financeRouter);
113114
app.use('/calendar', calendarRouter);
114115
app.use('/prospective-sponsors', prospectiveSponsorRouter);
116+
app.use('/attendance', attendanceRouter);
115117
app.use('/', (_req, res) => {
116118
res.status(200).json('Welcome to FinishLine');
117119
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextFunction, Request, Response } from 'express';
2+
import AttendanceService from '../services/attendance.services.js';
3+
4+
export default class AttendanceController {
5+
static async takeAttendance(req: Request, res: Response, next: NextFunction) {
6+
try {
7+
const { teamId, message } = req.body;
8+
const attendance = await AttendanceService.takeAttendance(req.currentUser, teamId, message, req.organization);
9+
res.status(200).json(attendance);
10+
} catch (error: unknown) {
11+
next(error);
12+
}
13+
}
14+
15+
static async getAllAttendances(req: Request, res: Response, next: NextFunction) {
16+
try {
17+
const attendances = await AttendanceService.getAllAttendances(req.organization);
18+
res.status(200).json(attendances);
19+
} catch (error: unknown) {
20+
next(error);
21+
}
22+
}
23+
24+
static async getOngoingAttendance(req: Request, res: Response, next: NextFunction) {
25+
try {
26+
const { teamId } = req.params as Record<string, string>;
27+
const attendance = await AttendanceService.getOngoingAttendance(teamId, req.organization);
28+
res.status(200).json(attendance);
29+
} catch (error: unknown) {
30+
next(error);
31+
}
32+
}
33+
34+
static async closeOngoingAttendance(req: Request, res: Response, next: NextFunction) {
35+
try {
36+
const { teamId } = req.params as Record<string, string>;
37+
await AttendanceService.closeOngoingAttendance(teamId, req.currentUser, req.organization);
38+
res.status(200).json({ message: 'Attendance closed successfully' });
39+
} catch (error: unknown) {
40+
next(error);
41+
}
42+
}
43+
44+
static async checkChannel(req: Request, res: Response, next: NextFunction) {
45+
try {
46+
const { teamId } = req.params as Record<string, string>;
47+
const result = await AttendanceService.checkTeamChannel(teamId, req.organization);
48+
res.status(200).json(result);
49+
} catch (error: unknown) {
50+
next(error);
51+
}
52+
}
53+
}

src/backend/src/integrations/slack.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,26 @@ export const editMessage = async (
143143
}
144144
};
145145

146+
/**
147+
* Deletes a slack message
148+
* @param channelId - the channel id of the channel containing the message
149+
* @param timestamp - the timestamp of the message to delete
150+
*/
151+
export const deleteMessage = async (channelId: string, timestamp: string) => {
152+
const client = getSlackClient();
153+
if (!client) return;
154+
155+
try {
156+
await client.chat.delete({
157+
channel: channelId,
158+
ts: timestamp
159+
});
160+
} catch (error) {
161+
console.error('Failed to delete Slack message:', (error as any)?.data?.error ?? error);
162+
return undefined;
163+
}
164+
};
165+
146166
/**
147167
* Reacts to a slack message
148168
* @param slackId - the channel id of the channel of the message to reply to
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Prisma } from '@prisma/client';
2+
import { getUserQueryArgs } from './user.query-args.js';
3+
4+
export type MeetingAttendanceQueryArgs = ReturnType<typeof getMeetingAttendanceQueryArgs>;
5+
6+
export const getMeetingAttendanceQueryArgs = (organizationId: string) =>
7+
Prisma.validator<Prisma.Meeting_AttendanceDefaultArgs>()({
8+
include: {
9+
userCreated: getUserQueryArgs(organizationId),
10+
team: {
11+
select: {
12+
teamId: true,
13+
teamName: true,
14+
headId: true,
15+
members: { select: { userId: true } },
16+
leads: { select: { userId: true } }
17+
}
18+
},
19+
attendees: { select: { userId: true } }
20+
}
21+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- CreateTable
2+
CREATE TABLE "Meeting_Attendance" (
3+
"meetingAttendanceId" TEXT NOT NULL,
4+
"organizationId" TEXT NOT NULL,
5+
"teamId" TEXT NOT NULL,
6+
"userCreatedId" TEXT NOT NULL,
7+
"openedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
"closedAt" TIMESTAMP(3),
9+
"slackChannelId" TEXT NOT NULL,
10+
"slackMessageTimestamp" TEXT NOT NULL,
11+
12+
CONSTRAINT "Meeting_Attendance_pkey" PRIMARY KEY ("meetingAttendanceId")
13+
);
14+
15+
-- CreateTable
16+
CREATE TABLE "_meetingAttendees" (
17+
"A" TEXT NOT NULL,
18+
"B" TEXT NOT NULL,
19+
20+
CONSTRAINT "_meetingAttendees_AB_pkey" PRIMARY KEY ("A","B")
21+
);
22+
23+
-- CreateIndex
24+
CREATE INDEX "Meeting_Attendance_organizationId_idx" ON "Meeting_Attendance"("organizationId");
25+
26+
-- CreateIndex
27+
CREATE INDEX "Meeting_Attendance_teamId_idx" ON "Meeting_Attendance"("teamId");
28+
29+
-- CreateIndex
30+
CREATE INDEX "_meetingAttendees_B_index" ON "_meetingAttendees"("B");
31+
32+
-- AddForeignKey
33+
ALTER TABLE "Meeting_Attendance" ADD CONSTRAINT "Meeting_Attendance_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE;
34+
35+
-- AddForeignKey
36+
ALTER TABLE "Meeting_Attendance" ADD CONSTRAINT "Meeting_Attendance_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE;
37+
38+
-- AddForeignKey
39+
ALTER TABLE "Meeting_Attendance" ADD CONSTRAINT "Meeting_Attendance_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE;
40+
41+
-- AddForeignKey
42+
ALTER TABLE "_meetingAttendees" ADD CONSTRAINT "_meetingAttendees_A_fkey" FOREIGN KEY ("A") REFERENCES "Meeting_Attendance"("meetingAttendanceId") ON DELETE CASCADE ON UPDATE CASCADE;
43+
44+
-- AddForeignKey
45+
ALTER TABLE "_meetingAttendees" ADD CONSTRAINT "_meetingAttendees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE;

src/backend/src/prisma/schema.prisma

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ model User {
303303
leadershipCrAsLead Leadership_CR[] @relation(name: "leadershipCrLead")
304304
leadershipCrAsManager Leadership_CR[] @relation(name: "leadershipCrManager")
305305
prospectiveSponsorsContacted Prospective_Sponsor[] @relation(name: "prospectiveSponsorContactor")
306+
createdMeetingAttendances Meeting_Attendance[] @relation(name: "meetingAttendanceCreated")
307+
attendedMeetingAttendances Meeting_Attendance[] @relation(name: "meetingAttendees")
306308
}
307309

308310
model Role {
@@ -339,6 +341,7 @@ model Team {
339341
organization Organization @relation(fields: [organizationId], references: [organizationId])
340342
checklists Checklist[]
341343
projectTemplates Project_Template[]
344+
meetingAttendances Meeting_Attendance[]
342345
343346
@@index([headId])
344347
@@index([organizationId])
@@ -1444,6 +1447,7 @@ model Organization {
14441447
machineries Machinery[]
14451448
calendars Calendar[]
14461449
eventTypes Event_Type[]
1450+
meetingAttendances Meeting_Attendance[]
14471451
}
14481452

14491453
model FrequentlyAskedQuestion {
@@ -1801,3 +1805,21 @@ model Guest_Definition {
18011805
18021806
@@index([organizationId])
18031807
}
1808+
1809+
model Meeting_Attendance {
1810+
meetingAttendanceId String @id @default(uuid())
1811+
organizationId String
1812+
organization Organization @relation(fields: [organizationId], references: [organizationId])
1813+
teamId String
1814+
team Team @relation(fields: [teamId], references: [teamId])
1815+
userCreatedId String
1816+
userCreated User @relation(name: "meetingAttendanceCreated", fields: [userCreatedId], references: [userId])
1817+
openedAt DateTime @default(now())
1818+
closedAt DateTime?
1819+
slackChannelId String
1820+
slackMessageTimestamp String
1821+
attendees User[] @relation(name: "meetingAttendees")
1822+
1823+
@@index([organizationId])
1824+
@@index([teamId])
1825+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import express from 'express';
2+
import { body } from 'express-validator';
3+
import { nonEmptyString, validateInputs } from '../utils/validation.utils.js';
4+
import AttendanceController from '../controllers/attendance.controllers.js';
5+
6+
const attendanceRouter = express.Router();
7+
8+
attendanceRouter.post(
9+
'/',
10+
nonEmptyString(body('teamId')),
11+
nonEmptyString(body('message')),
12+
validateInputs,
13+
AttendanceController.takeAttendance
14+
);
15+
16+
attendanceRouter.get('/', AttendanceController.getAllAttendances);
17+
18+
attendanceRouter.get('/ongoing/:teamId', AttendanceController.getOngoingAttendance);
19+
attendanceRouter.post('/close/:teamId', AttendanceController.closeOngoingAttendance);
20+
attendanceRouter.get('/check-channel/:teamId', AttendanceController.checkChannel);
21+
22+
export default attendanceRouter;

src/backend/src/routes/slack.routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getSlackApp } from '../integrations/slack.js';
22
import SlackController from '../controllers/slack.controllers.js';
3+
import AttendanceService from '../services/attendance.services.js';
34

45
// Register Slack event listeners only if the Slack app is configured
56
const slackApp = getSlackApp();
@@ -14,6 +15,18 @@ if (slackApp) {
1415
console.error(error);
1516
}
1617
});
18+
19+
// Register reaction_added event listener for attendance tracking
20+
slackApp.event('reaction_added', async ({ event, logger }: any) => {
21+
try {
22+
const { user, item } = event;
23+
if (item.type !== 'message') return;
24+
await AttendanceService.handleReactionAdded(user, item.channel, item.ts);
25+
} catch (error) {
26+
logger.error('Error handling reaction_added event:', error);
27+
console.error(error);
28+
}
29+
});
1730
}
1831

1932
/**

0 commit comments

Comments
 (0)