Skip to content

Commit 39766ed

Browse files
authored
Merge pull request #4197 from Northeastern-Electric-Racing/ics-integration
ics calendar integration
2 parents 7be84fb + 190f6b9 commit 39766ed

15 files changed

Lines changed: 332 additions & 1 deletion

File tree

src/backend/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import financeRouter from './src/routes/finance.routes.js';
2929
import calendarRouter from './src/routes/calendar.routes.js';
3030
import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js';
3131
import attendanceRouter from './src/routes/attendance.routes.js';
32+
import icsRouter from './src/routes/ics.routes.js';
3233

3334
const app = express();
3435

@@ -85,6 +86,9 @@ app.use(express.json());
8586
// cors settings
8687
app.use(cors(options));
8788

89+
// Public ICS feed routes — mounted before JWT middleware so calendar apps can subscribe without auth
90+
app.use('/ics', icsRouter);
91+
8892
// ensure each request is authorized using JWT
8993
app.use(isProd ? requireJwtProd : requireJwtDev);
9094

src/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"express-validator": "^6.14.2",
3131
"google-auth-library": "^8.1.1",
3232
"googleapis": "^118.0.0",
33+
"ical-generator": "^10.2.0",
3334
"jsonwebtoken": "^8.5.1",
3435
"multer": "^1.4.5-lts.1",
3536
"nodemailer": "^6.9.1",

src/backend/src/controllers/calendar.controllers.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextFunction, Request, Response } from 'express';
22
import CalendarService from '../services/calendar.services.js';
33
import { getCurrentUserWithUserSettings } from '../utils/auth.utils.js';
4+
import { generateIcsFeed } from '../utils/ics.utils.js';
45

56
export default class CalendarController {
67
static async createEventType(req: Request, res: Response, next: NextFunction) {
@@ -610,4 +611,31 @@ export default class CalendarController {
610611
next(error);
611612
}
612613
}
614+
615+
static async getOrCreateIcsToken(req: Request, res: Response, next: NextFunction) {
616+
try {
617+
const icsToken = await CalendarService.getOrCreateIcsToken(req.currentUser.userId);
618+
res.status(200).json({ icsToken, organizationId: req.organization.organizationId });
619+
} catch (error: unknown) {
620+
next(error);
621+
}
622+
}
623+
624+
static async getIcsFeed(req: Request, res: Response, next: NextFunction) {
625+
try {
626+
const { token } = req.params as Record<string, string>;
627+
const { org, calendars } = req.query as Record<string, string | undefined>;
628+
const organizationId = org ?? '';
629+
const calendarIds = calendars ? calendars.split(',').filter(Boolean) : [];
630+
631+
const events = await CalendarService.getIcsFeedEvents(token, organizationId, calendarIds);
632+
const icsContent = generateIcsFeed(events);
633+
634+
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
635+
res.setHeader('Content-Disposition', 'attachment; filename="finishline.ics"');
636+
res.send(icsContent);
637+
} catch (error: unknown) {
638+
next(error);
639+
}
640+
}
613641
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "icsToken" TEXT;
3+
4+
-- CreateIndex
5+
CREATE UNIQUE INDEX "User_icsToken_key" ON "User"("icsToken");

src/backend/src/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ model User {
191191
additionalPermissions String[]
192192
userSettings User_Settings?
193193
userSecureSettings User_Secure_Settings?
194+
icsToken String? @unique
194195
195196
// Relation references
196197
submittedChangeRequests Change_Request[] @relation(name: "submittedChangeRequests")

src/backend/src/routes/calendar.routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,5 +298,6 @@ calendarRouter.post(
298298

299299
calendarRouter.get('/calendars', CalendarController.getAllCalendars);
300300
calendarRouter.post('/events-paginated', CalendarController.getAllEventsPaginated);
301+
calendarRouter.get('/ics/token', CalendarController.getOrCreateIcsToken);
301302

302303
export default calendarRouter;
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 CalendarController from '../controllers/calendar.controllers.js';
3+
4+
const icsRouter = express.Router();
5+
6+
icsRouter.get('/:token', CalendarController.getIcsFeed);
7+
8+
export default icsRouter;

src/backend/src/services/calendar.services.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2813,4 +2813,55 @@ export default class CalendarService {
28132813
nextPastCursor: pastSlots.length === 25 ? pastSlots[pastSlots.length - 1].startTime : null
28142814
};
28152815
}
2816+
2817+
static async getOrCreateIcsToken(userId: string): Promise<string> {
2818+
const user = await prisma.user.findUnique({ where: { userId } });
2819+
if (!user) throw new NotFoundException('User', userId);
2820+
if (user.icsToken) return user.icsToken;
2821+
2822+
const token = crypto.randomUUID();
2823+
await prisma.user.update({ where: { userId }, data: { icsToken: token } });
2824+
return token;
2825+
}
2826+
2827+
static async getIcsFeedEvents(icsToken: string, organizationId: string, calendarIds: string[]) {
2828+
const user = await prisma.user.findUnique({
2829+
where: { icsToken },
2830+
include: {
2831+
teamsAsMember: { select: { teamId: true } },
2832+
teamsAsLead: { select: { teamId: true } },
2833+
teamsAsHead: { select: { teamId: true } }
2834+
}
2835+
});
2836+
2837+
if (!user) throw new NotFoundException('User', 'icsToken');
2838+
2839+
const userTeamIds = [
2840+
...user.teamsAsMember.map((t) => t.teamId),
2841+
...user.teamsAsLead.map((t) => t.teamId),
2842+
...user.teamsAsHead.map((t) => t.teamId)
2843+
];
2844+
2845+
const calendarFilter =
2846+
calendarIds.length > 0
2847+
? [{ eventType: { calendars: { some: { calendarId: { in: calendarIds }, organizationId } } } }]
2848+
: [];
2849+
2850+
const events = await prisma.event.findMany({
2851+
where: {
2852+
dateDeleted: null,
2853+
status: { in: [Event_Status.CONFIRMED, Event_Status.SCHEDULED, Event_Status.DONE] },
2854+
scheduledTimes: { some: {} },
2855+
OR: [
2856+
{ requiredMembers: { some: { userId: user.userId } } },
2857+
{ optionalMembers: { some: { userId: user.userId } } },
2858+
...(userTeamIds.length > 0 ? [{ teams: { some: { teamId: { in: userTeamIds } } } }] : []),
2859+
...calendarFilter
2860+
]
2861+
},
2862+
...getEventQueryArgs(organizationId)
2863+
});
2864+
2865+
return events.map(eventTransformer);
2866+
}
28162867
}

src/backend/src/utils/ics.utils.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import ical, { ICalEventStatus } from 'ical-generator';
2+
import { Event, wbsPipe } from 'shared';
3+
4+
export const generateIcsFeed = (events: Event[]): string => {
5+
const cal = ical({ name: 'Northeastern Electric Racing' });
6+
7+
for (const event of events) {
8+
for (const slot of event.scheduledTimes) {
9+
const descriptionParts: string[] = [];
10+
if (event.description) descriptionParts.push(event.description);
11+
12+
const memberName = (m: { firstName: string; lastName: string }) => `${m.firstName} ${m.lastName}`;
13+
if (event.requiredMembers.length > 0)
14+
descriptionParts.push(`Required: ${event.requiredMembers.map(memberName).join(', ')}`);
15+
if (event.optionalMembers.length > 0)
16+
descriptionParts.push(`Optional: ${event.optionalMembers.map(memberName).join(', ')}`);
17+
18+
if (event.zoomLink) descriptionParts.push(`Zoom: ${event.zoomLink}`);
19+
if (event.teams.length > 0) descriptionParts.push(`Teams: ${event.teams.map((team) => team.teamName).join(', ')}`);
20+
if (event.shops.length > 0) descriptionParts.push(`Shops: ${event.shops.map((shop) => shop.name).join(', ')}`);
21+
if (event.machinery.length > 0)
22+
descriptionParts.push(`Machinery: ${event.machinery.map((machine) => machine.name).join(', ')}`);
23+
if (event.workPackages.length > 0)
24+
descriptionParts.push(
25+
`Work Package: ${event.workPackages.map((wp) => `${wp.wbsElement.name} (${wbsPipe(wp.wbsElement)})`).join(', ')}`
26+
);
27+
28+
cal.createEvent({
29+
id: `${event.eventId}-${slot.scheduleSlotId}@finishlinebyner.com`,
30+
summary: event.title,
31+
start: slot.startTime,
32+
end: slot.endTime,
33+
allDay: slot.allDay,
34+
description: descriptionParts.length > 0 ? descriptionParts.join('\n\n') : undefined,
35+
location: event.location ?? event.zoomLink ?? undefined,
36+
status: ICalEventStatus.CONFIRMED,
37+
organizer: { name: event.userCreated.firstName + ' ' + event.userCreated.lastName, email: event.userCreated.email }
38+
});
39+
}
40+
}
41+
42+
return cal.toString();
43+
};

src/frontend/src/apis/calendar.api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,9 @@ export const getAllEventsPaginated = (futureCursor?: Date, pastCursor?: Date) =>
290290
}
291291
);
292292
};
293+
294+
export const getIcsToken = () => {
295+
return axios.get<{ icsToken: string; organizationId: string }>(apiUrls.calendarIcsToken(), {
296+
transformResponse: (data) => JSON.parse(data)
297+
});
298+
};

0 commit comments

Comments
 (0)