From bbbcccaf1be049c0bd648011af4660b404aae854 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:54:39 -0400 Subject: [PATCH 1/7] #4232 import gcal availability --- src/backend/package.json | 1 + .../src/controllers/users.controllers.ts | 22 ++- .../migration.sql | 2 + src/backend/src/prisma/schema.prisma | 13 +- src/backend/src/routes/users.routes.ts | 10 +- src/backend/src/services/users.services.ts | 60 ++++++- .../user-schedule-settings.transformer.ts | 3 +- src/backend/src/utils/ics.utils.ts | 168 +++++++++++++++++- src/backend/tests/unit/ics.utils.test.ts | 70 ++++++++ src/frontend/src/apis/users.api.ts | 17 ++ src/frontend/src/hooks/users.hooks.ts | 28 ++- .../Components/EventAvailabilityPage.tsx | 3 + .../CalendarPage/Components/EventTimeSlot.tsx | 5 + .../Availability/AvailabilityEditModal.tsx | 6 +- .../Availability/EditAvailability.tsx | 89 +++++++++- .../Availability/SingleAvailabilityModal.tsx | 10 +- .../Availability/SingleAvailabilityView.tsx | 29 ++- .../UserScheduleSettings.tsx | 2 + .../UserScheduleSettingsEdit.tsx | 56 +++++- .../UserScheduleSettingsView.tsx | 9 + src/frontend/src/utils/ics.utils.ts | 24 +++ src/frontend/src/utils/urls.ts | 2 + yarn.lock | 52 ++++++ 23 files changed, 647 insertions(+), 34 deletions(-) create mode 100644 src/backend/src/prisma/migrations/20260602212459_import_ics_url/migration.sql create mode 100644 src/backend/tests/unit/ics.utils.test.ts create mode 100644 src/frontend/src/utils/ics.utils.ts diff --git a/src/backend/package.json b/src/backend/package.json index 0a97da5876..12237c02ae 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -33,6 +33,7 @@ "ical-generator": "^10.2.0", "jsonwebtoken": "^8.5.1", "multer": "^1.4.5-lts.1", + "node-ical": "^0.26.1", "nodemailer": "^6.9.1", "prisma": "^6.2.1", "shared": "1.0.0" diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index 75fe877002..fd92225a98 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -173,13 +173,14 @@ export default class UsersController { static async setUserScheduleSettings(req: Request, res: Response, next: NextFunction) { try { - const { personalGmail, personalZoomLink, availability } = req.body; + const { personalGmail, personalZoomLink, availability, importedIcsCalendarUrl } = req.body; const updatedScheduleSettings = await UsersService.setUserScheduleSettings( req.currentUser, personalGmail, personalZoomLink, - availability + availability, + importedIcsCalendarUrl ); res.status(200).json(updatedScheduleSettings); @@ -199,6 +200,23 @@ export default class UsersController { } } + static async getUserIcsBusyTimes(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params as Record; + const { startDate, endDate } = req.query as Record; + + const busyTimes = await UsersService.getUserIcsBusyTimes( + userId, + req.currentUser, + new Date(startDate), + new Date(endDate) + ); + res.status(200).json(busyTimes); + } catch (error: unknown) { + next(error); + } + } + static async getUserTasks(req: Request, res: Response, next: NextFunction) { try { const { userId } = req.params as Record; diff --git a/src/backend/src/prisma/migrations/20260602212459_import_ics_url/migration.sql b/src/backend/src/prisma/migrations/20260602212459_import_ics_url/migration.sql new file mode 100644 index 0000000000..305adafd25 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260602212459_import_ics_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Schedule_Settings" ADD COLUMN "importedIcsCalendarUrl" TEXT NOT NULL DEFAULT ''; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..f245fad3a6 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -1265,12 +1265,13 @@ model Availability { } model Schedule_Settings { - drScheduleSettingsId String @id @default(uuid()) - personalGmail String - personalZoomLink String - User User @relation(fields: [userId], references: [userId]) - userId String @unique - availabilities Availability[] + drScheduleSettingsId String @id @default(uuid()) + personalGmail String + personalZoomLink String + User User @relation(fields: [userId], references: [userId]) + userId String @unique + availabilities Availability[] + importedIcsCalendarUrl String @default("") } model Wbs_Proposed_Changes { diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 98a7b6b21f..3569025bdb 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -1,6 +1,6 @@ import { Theme } from '@prisma/client'; import express from 'express'; -import { body } from 'express-validator'; +import { body, query } from 'express-validator'; import UsersController from '../controllers/users.controllers.js'; import { isRole, nonEmptyString, intMinZero, validateInputs, isDateOnly } from '../utils/validation.utils.js'; @@ -47,6 +47,7 @@ userRouter.post( '/schedule-settings/set', body('personalGmail').isString(), body('personalZoomLink').isString(), + body('importedIcsCalendarUrl').optional().isString(), body('availability').isArray(), body('availability.*.availability').isArray(), intMinZero(body('availability.*.availability.*')), @@ -57,6 +58,13 @@ userRouter.post( userRouter.get('/:userId/secure-settings', UsersController.getUserSecureSettings); userRouter.get('/:userId/schedule-settings', UsersController.getUserScheduleSettings); +userRouter.get( + '/:userId/schedule-settings/ics-busy', + isDateOnly(query('startDate')), + isDateOnly(query('endDate')), + validateInputs, + UsersController.getUserIcsBusyTimes +); userRouter.get('/:userId/tasks', UsersController.getUserTasks); userRouter.post( '/tasks/get-many', diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 69bf4cf9e5..90fd984e81 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -12,10 +12,12 @@ import { AvailabilityCreateArgs, UserWithScheduleSettings, ProjectOverview, - isAtLeastRank + isAtLeastRank, + IcsBusySlots } from 'shared'; import prisma from '../prisma/prisma.js'; import { AccessDeniedException, HttpException, NotFoundException } from '../utils/errors.utils.js'; +import { busyIntervalsToSlots, fetchIcsBusyTimes, validateIcsUrl } from '../utils/ics.utils.js'; import { generateAccessToken } from '../utils/auth.utils.js'; import { projectOverviewTransformer } from '../transformers/projects.transformer.js'; import { getProjectOverviewQueryArgs } from '../prisma-query-args/projects.query-args.js'; @@ -525,7 +527,8 @@ export default class UsersService { user: User, personalGmail: string, personalZoomLink: string, - availabilities: AvailabilityCreateArgs[] + availabilities: AvailabilityCreateArgs[], + importedIcsCalendarUrl?: string ): Promise { if (personalGmail !== '') { const existingUser = await prisma.schedule_Settings.findFirst({ @@ -537,16 +540,20 @@ export default class UsersService { } } + if (importedIcsCalendarUrl) validateIcsUrl(importedIcsCalendarUrl); + const newUserScheduleSettings = await prisma.schedule_Settings.upsert({ where: { userId: user.userId }, update: { personalGmail, - personalZoomLink + personalZoomLink, + importedIcsCalendarUrl }, create: { userId: user.userId, personalGmail, - personalZoomLink + personalZoomLink, + importedIcsCalendarUrl }, ...getUserScheduleSettingsQueryArgs() }); @@ -622,4 +629,49 @@ export default class UsersService { return users.map(userWithScheduleSettingsTransformer); } + + /** + * Read-only busy-times for a user's imported ICS calendar over [startDate, endDate), mapped onto the + * 0-11 availability slots per day. + * + * @param userId the user whose imported calendar is being read + * @param submitter the requesting user + * @param startDate the first day of the range (inclusive) + * @param endDate the day after the last day of the range (exclusive) + * @returns the busy slots per day, only including days that have at least one busy slot + */ + static async getUserIcsBusyTimes( + userId: string, + submitter: User, + startDate: Date, + endDate: Date + ): Promise { + if (submitter.userId !== userId) throw new AccessDeniedException('You can only access your own schedule settings'); + + const scheduleSettings = await prisma.schedule_Settings.findUnique({ where: { userId } }); + if (!scheduleSettings?.importedIcsCalendarUrl) return []; + + let busy; + try { + busy = await fetchIcsBusyTimes(scheduleSettings.importedIcsCalendarUrl, startDate, endDate); + } catch (error) { + if (error instanceof HttpException) { + console.error( + `Failed to fetch ICS busy-times for schedule settings ${scheduleSettings.drScheduleSettingsId}: ${error.message}` + ); + return []; + } + throw error; + } + + if (busy.length === 0) return []; + + const busyDays: IcsBusySlots[] = []; + for (let day = new Date(startDate); day < endDate; day.setUTCDate(day.getUTCDate() + 1)) { + const busySlots = busyIntervalsToSlots(busy, day); + if (busySlots.size > 0) busyDays.push({ dateSet: new Date(day), busySlots: Array.from(busySlots) }); + } + + return busyDays; + } } diff --git a/src/backend/src/transformers/user-schedule-settings.transformer.ts b/src/backend/src/transformers/user-schedule-settings.transformer.ts index 44e66f8082..af10bdd196 100644 --- a/src/backend/src/transformers/user-schedule-settings.transformer.ts +++ b/src/backend/src/transformers/user-schedule-settings.transformer.ts @@ -9,7 +9,8 @@ const userScheduleSettingsTransformer = ( drScheduleSettingsId: settings.drScheduleSettingsId, personalGmail: settings.personalGmail, personalZoomLink: settings.personalZoomLink, - availabilities: settings.availabilities + availabilities: settings.availabilities, + importedIcsCalendarUrl: settings.importedIcsCalendarUrl }; }; diff --git a/src/backend/src/utils/ics.utils.ts b/src/backend/src/utils/ics.utils.ts index f2c79e2988..52195c16fe 100644 --- a/src/backend/src/utils/ics.utils.ts +++ b/src/backend/src/utils/ics.utils.ts @@ -1,5 +1,7 @@ import ical, { ICalEventStatus } from 'ical-generator'; -import { Event, wbsPipe } from 'shared'; +import nodeIcal, { CalendarComponent, VEvent } from 'node-ical'; +import { IcsBusyInterval, Event, wbsPipe } from 'shared'; +import { HttpException } from './errors.utils.js'; export const generateIcsFeed = (events: Event[]): string => { const cal = ical({ name: 'Northeastern Electric Racing' }); @@ -41,3 +43,167 @@ export const generateIcsFeed = (events: Event[]): string => { return cal.toString(); }; + +// checks if a given host is blocked (used to mitigate ssrf attacks) +const isBlockedHost = (host: string): boolean => { + const h = host.toLowerCase(); + return ( + h === 'localhost' || + h === '127.0.0.1' || + h === '0.0.0.0' || + h === '::1' || + h.endsWith('.local') || + /^10\./.test(h) || + /^192\.168\./.test(h) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(h) || + /^169\.254\./.test(h) + ); +}; + +// checks if a give ics url is valid, throws if invalid +export const validateIcsUrl = (url: string): URL => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new HttpException(400, 'Invalid ICS URL'); + } + + if (parsed.protocol === 'webcal:') parsed.protocol = 'https:'; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new HttpException(400, 'ICS URL must use http or https'); + } + if (isBlockedHost(parsed.hostname)) { + throw new HttpException(400, 'ICS URL host is not allowed'); + } + return parsed; +}; + +// fetches the text from the ics url +const fetchIcsText = async (url: URL): Promise => { + // timeout after 10,000 ms + const fetchTimeoutMs = 10000; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + try { + const res = await fetch(url, { signal: controller.signal, redirect: 'follow' }); + if (!res.ok) throw new HttpException(502, `Failed to fetch ICS feed (status ${res.status})`); + return await res.text(); + } catch (err) { + if (err instanceof HttpException) throw err; + if (err instanceof Error && err.name === 'AbortError') { + throw new HttpException(504, 'ICS feed fetch timed out'); + } + throw new HttpException(502, 'Failed to fetch ICS feed'); + } finally { + clearTimeout(timeout); + } +}; + +/** + * Fetches an ICS calendar feed and returns busy intervals overlapping [rangeStart, rangeEnd). + * Expands RRULE recurrences, applies EXDATE exclusions, and honors per-occurrence overrides + * (RECURRENCE-ID). Skips events marked CANCELLED or TRANSPARENT (free). + */ +export const fetchIcsBusyTimes = async (url: string, rangeStart: Date, rangeEnd: Date): Promise => { + const validUrl = validateIcsUrl(url); + const icsText = await fetchIcsText(validUrl); + + let parsed: Record; + try { + parsed = nodeIcal.sync.parseICS(icsText); + } catch { + throw new HttpException(400, 'ICS feed could not be parsed'); + } + + const busy: IcsBusyInterval[] = []; + + for (const component of Object.values(parsed)) { + if (!component || component.type !== 'VEVENT') continue; + const ev = component as VEvent; + + if (ev.status === 'CANCELLED') continue; + if ((ev as unknown as { transparency?: string }).transparency === 'TRANSPARENT') continue; + + const baseStart = ev.start as Date | undefined; + const baseEnd = ev.end as Date | undefined; + if (!baseStart || !baseEnd) continue; + + const { rrule } = ev as unknown as { rrule?: { between: (a: Date, b: Date, inc: boolean) => Date[] } }; + + if (!rrule) { + if (baseEnd > rangeStart && baseStart < rangeEnd) { + busy.push({ start: baseStart, end: baseEnd }); + } + continue; + } + + const durationMs = baseEnd.getTime() - baseStart.getTime(); + const occurrences = rrule.between(rangeStart, rangeEnd, true); + + const exdateMap = (ev as unknown as { exdate?: Record }).exdate ?? {}; + const exdateTimes = new Set(Object.values(exdateMap).map((d) => d.getTime())); + + const recurrenceMap = (ev as unknown as { recurrences?: Record }).recurrences ?? {}; + const recurrencesByTime = new Map(); + for (const override of Object.values(recurrenceMap)) { + const recId = (override as unknown as { recurrenceid?: Date }).recurrenceid ?? (override.start as Date); + if (recId) recurrencesByTime.set(recId.getTime(), override); + } + + for (const occ of occurrences) { + const occTime = occ.getTime(); + if (exdateTimes.has(occTime)) continue; + + const override = recurrencesByTime.get(occTime); + if (override) { + if (override.status === 'CANCELLED') continue; + const oStart = override.start as Date | undefined; + const oEnd = override.end as Date | undefined; + if (oStart && oEnd && oEnd > rangeStart && oStart < rangeEnd) { + busy.push({ start: oStart, end: oEnd }); + } + continue; + } + + const occEnd = new Date(occTime + durationMs); + if (occEnd > rangeStart && occ < rangeEnd) { + busy.push({ start: occ, end: occEnd }); + } + } + } + + return busy; +}; + +// converts ics date to utc midnight for that day +export const localDayStartForDateSet = (dateSet: Date | string): Date => { + const date = new Date(dateSet); + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); +}; + +// converts the ics busy intervals into availability slots (0-11) +export const busyIntervalsToSlots = (busy: IcsBusyInterval[], dateSet: Date | string): Set => { + const dayStart = localDayStartForDateSet(dateSet); + const busySlots = new Set(); + + const availabilityStart = 10; + const numAvailabilitySlots = 12; + + for (let slot = 0; slot < numAvailabilitySlots; slot++) { + const slotStart = new Date(dayStart); + slotStart.setHours(availabilityStart + slot, 0, 0, 0); + const slotEnd = new Date(slotStart); + slotEnd.setHours(slotEnd.getHours() + 1); + + if (busy.some((interval) => interval.start < slotEnd && interval.end > slotStart)) { + busySlots.add(slot); + } + } + + return busySlots; +}; + +// returns given availability with ics busy slots removed +export const removeBusySlotsFromAvailability = (availability: number[], busySlots: Set): number[] => + availability.filter((slot) => !busySlots.has(slot)); diff --git a/src/backend/tests/unit/ics.utils.test.ts b/src/backend/tests/unit/ics.utils.test.ts new file mode 100644 index 0000000000..9b2b7b6ace --- /dev/null +++ b/src/backend/tests/unit/ics.utils.test.ts @@ -0,0 +1,70 @@ +import { busyIntervalsToSlots, removeBusySlotsFromAvailability } from '../../src/utils/ics.utils.js'; + +describe('ICS Util Tests', () => { + const at = (day: Date, hour: number, minutes = 0): Date => { + const date = new Date(day); + date.setHours(hour, minutes, 0, 0); + return date; + }; + + describe('busyIntervalsToSlots', () => { + const day = new Date('2026-06-01T12:00:00'); + + it('returns no busy slots when there are no intervals', () => { + expect(busyIntervalsToSlots([], day)).toEqual(new Set()); + }); + + it('maps an interval onto the slots it fully covers', () => { + const busy = busyIntervalsToSlots([{ start: at(day, 10), end: at(day, 12) }], day); + expect(busy).toEqual(new Set([0, 1])); + }); + + it('marks a slot busy when an interval only partially overlaps it', () => { + const busy = busyIntervalsToSlots([{ start: at(day, 13, 30), end: at(day, 14, 30) }], day); + expect(busy).toEqual(new Set([3, 4])); + }); + + it('ignores intervals outside the 10am-10pm window', () => { + const busy = busyIntervalsToSlots( + [ + { start: at(day, 8), end: at(day, 9) }, + { start: at(day, 22), end: at(day, 23) } + ], + day + ); + expect(busy).toEqual(new Set()); + }); + + it('ignores intervals on a different day', () => { + const otherDay = new Date('2026-06-02T12:00:00'); + const busy = busyIntervalsToSlots([{ start: at(otherDay, 12), end: at(otherDay, 13) }], day); + expect(busy).toEqual(new Set()); + }); + + it('uses an exclusive end so a slot-boundary interval does not bleed into the next slot', () => { + const busy = busyIntervalsToSlots([{ start: at(day, 10), end: at(day, 11) }], day); + expect(busy).toEqual(new Set([0])); + }); + + it('maps slots onto the UTC calendar date of dateSet, not the timezone-shifted local date', () => { + const dateSet = new Date('2026-06-02T00:00:00.000Z'); + const localStart = new Date(2026, 5, 2, 10, 0, 0, 0); // 10-11am local on June 2 + const localEnd = new Date(2026, 5, 2, 11, 0, 0, 0); + expect(busyIntervalsToSlots([{ start: localStart, end: localEnd }], dateSet)).toEqual(new Set([0])); + }); + }); + + describe('removeBusySlotsFromAvailability', () => { + it('removes only the slots that are busy', () => { + expect(removeBusySlotsFromAvailability([0, 1, 2, 3], new Set([1, 3]))).toEqual([0, 2]); + }); + + it('returns the availability unchanged when nothing is busy', () => { + expect(removeBusySlotsFromAvailability([0, 1, 2], new Set())).toEqual([0, 1, 2]); + }); + + it('returns an empty array when every available slot is busy', () => { + expect(removeBusySlotsFromAvailability([4, 5], new Set([4, 5]))).toEqual([]); + }); + }); +}); diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index 4da38b9489..b30383b054 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -6,6 +6,7 @@ import axios from '../utils/axios'; import { dateToMidnightUTC, + IcsBusySlots, ProjectOverview, SetUserScheduleSettingsPayload, Task, @@ -212,3 +213,19 @@ export const getManyUsersWithScheduleSettings = (userIds: string[]) => { export const logUserOut = () => { return axios.post<{ message: string }>(apiUrls.logUserOut()); }; + +/** + * Gets a user's busy times from their ics calendar url. + * + * @returns their availability from ics calendar url. + */ +export const getUserIcsBusyTimes = (userId: string, startDate: Date, endDate: Date) => { + return axios.get(apiUrls.userScheduleSettingsIcsBusy(userId), { + params: { + startDate: dateToMidnightUTC(startDate).toISOString(), + endDate: dateToMidnightUTC(endDate).toISOString() + }, + transformResponse: (data) => + (JSON.parse(data) as IcsBusySlots[]).map((day) => ({ ...day, dateSet: new Date(day.dateSet) })) + }); +}; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index c890c23671..03d9cfda30 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -17,6 +17,7 @@ import { getCurrentUserSecureSettings, getUserSecureSettings, getUserScheduleSettings, + getUserIcsBusyTimes, updateUserScheduleSettings, getUserTasks, getManyUserTasks, @@ -37,7 +38,8 @@ import { Task, UserWithRole, UserWithScheduleSettings, - ProjectOverview + ProjectOverview, + IcsBusySlots } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -178,7 +180,13 @@ export const useUserScheduleSettings = (id: string) => { const { data } = await getUserScheduleSettings(id); return data; } catch (error: unknown) { - return { drScheduleSettingsId: '', personalGmail: '', personalZoomLink: '', availabilities: [] }; + return { + drScheduleSettingsId: '', + personalGmail: '', + personalZoomLink: '', + availabilities: [], + importedIcsCalendarUrl: '' + }; } }); }; @@ -322,3 +330,19 @@ export const useLogUserOut = () => { return data; }); }; + +/** + * Custom react hook to get a user's busy times from their ics calendar url + * + * @returns user's busy times from imported calendar + */ +export const useUserIcsBusyTimes = (id: string, startDate: Date, endDate: Date, enabled: boolean) => { + return useQuery( + ['users', id, 'schedule-settings', 'ics-busy', startDate.getTime(), endDate.getTime()], + async () => { + const { data } = await getUserIcsBusyTimes(id, startDate, endDate); + return data; + }, + { enabled: enabled && !!id } + ); +}; diff --git a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx index 35daeb1ff7..3ed4adfec1 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx @@ -254,6 +254,7 @@ export const EventAvailabilityPage: React.FC = () => { initialDate={displayDate} onSubmit={handleConfirm} canChangeDateRange={false} + showImportedCalendarBusy={!!userScheduleSettings.importedIcsCalendarUrl} /> ); } @@ -416,6 +417,7 @@ export const EventAvailabilityPage: React.FC = () => { header="My Availability" availabilites={userScheduleSettings.availabilities} initialDate={displayDate} + showImportedCalendarBusy={!!userScheduleSettings.importedIcsCalendarUrl} /> { initialDate={displayDate} onSubmit={handleConfirm} canChangeDateRange={false} + showImportedCalendarBusy={!!userScheduleSettings.importedIcsCalendarUrl} /> {selectedSlot && ( void; selected?: boolean; allRequiredAvailable?: boolean; + busy?: boolean; onMouseDown?: (e: React.MouseEvent) => void; onMouseEnter?: (e: React.MouseEvent) => void; onMouseUp?: () => void; @@ -15,6 +16,7 @@ const EventTimeSlot: React.FC = ({ onClick, selected = false, allRequiredAvailable = false, + busy = false, onMouseDown, onMouseEnter, onMouseUp @@ -46,6 +48,9 @@ const EventTimeSlot: React.FC = ({ sx={{ borderRadius: 0.5, bgcolor: backgroundColor, + backgroundImage: busy + ? 'repeating-linear-gradient(45deg, rgba(0,0,0,0.25) 0px, rgba(0,0,0,0.25) 2px, transparent 2px, transparent 6px)' + : 'none', width: '100%', height: '100%', minWidth: 24, diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx index 9dcd977b88..08833bba4f 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/AvailabilityEditModal.tsx @@ -16,6 +16,7 @@ interface DRCEditModalProps { onSubmit: () => void; initialDate: Date; canChangeDateRange?: boolean; + showImportedCalendarBusy?: boolean; } const AvailabilityEditModal: React.FC = ({ @@ -27,7 +28,8 @@ const AvailabilityEditModal: React.FC = ({ totalAvailabilities, onSubmit, initialDate, - canChangeDateRange = true + canChangeDateRange = true, + showImportedCalendarBusy }) => { const onCancel = () => { setConfirmedAvailabilities(new Map()); @@ -45,6 +47,7 @@ const AvailabilityEditModal: React.FC = ({ totalAvailabilities={totalAvailabilities} canChangeDateRange={canChangeDateRange} initialDate={initialDate} + showImportedCalendarBusy={showImportedCalendarBusy} /> = ({ totalAvailabilities={totalAvailabilities} canChangeDateRange={canChangeDateRange} initialDate={initialDate} + showImportedCalendarBusy={showImportedCalendarBusy} /> ); diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx index 10eed2bed8..c9d0a2bad0 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx @@ -6,16 +6,20 @@ import { TableContainer, TableHead, TableRow, + Tooltip, Typography, useMediaQuery } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { HeatmapColors, enumToArray, REVIEW_TIMES } from '../../../../utils/design-review.utils'; import { addDaysToDate, Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; import NERArrows from '../../../../components/NERArrows'; import { NERButton } from '../../../../components/NERButton'; import EventTimeSlot from '../../../CalendarPage/Components/EventTimeSlot'; +import { useCurrentUser, useUserIcsBusyTimes } from '../../../../hooks/users.hooks'; +import { icsBusySlotsByDay, isSlotBusy } from '../../../../utils/ics.utils'; +import { useToast } from '../../../../hooks/toasts.hooks'; interface EditAvailabilityProps { editedAvailabilities: Map; @@ -23,6 +27,7 @@ interface EditAvailabilityProps { totalAvailabilities: Availability[]; initialDate: Date; canChangeDateRange?: boolean; + showImportedCalendarBusy?: boolean; } const EditAvailability: React.FC = ({ @@ -30,12 +35,14 @@ const EditAvailability: React.FC = ({ totalAvailabilities, setEditedAvailabilities, initialDate, - canChangeDateRange = true + canChangeDateRange = true, + showImportedCalendarBusy = false }) => { + const currentUser = useCurrentUser(); + const toast = useToast(); const [currentlyDisplayedAvailabilities, setCurrentlyDisplayedAvailabilities] = useState(() => { const availabilities = Array.from(editedAvailabilities.values()); if (availabilities.length === 0) { - // Load existing availabilities instead of creating empty ones const existingForWeek = getMostRecentAvailabilities(totalAvailabilities, initialDate); existingForWeek.forEach((availability) => { @@ -50,6 +57,23 @@ const EditAvailability: React.FC = ({ const [isDragging, setIsDragging] = useState(false); + const weekStart = currentlyDisplayedAvailabilities[0]?.dateSet ?? initialDate; + const weekEnd = addDaysToDate( + currentlyDisplayedAvailabilities[currentlyDisplayedAvailabilities.length - 1]?.dateSet ?? initialDate, + 1 + ); + const { data: icsBusy, isFetching: icsBusyIsFetching } = useUserIcsBusyTimes( + currentUser.userId, + weekStart, + weekEnd, + showImportedCalendarBusy + ); + + const busyByDay = useMemo( + () => (showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>()), + [icsBusy, showImportedCalendarBusy] + ); + const handleMouseDown = (event: any, availability: Availability, selectedTime: number) => { event.preventDefault(); toggleTimeSlot(availability, selectedTime); @@ -108,6 +132,30 @@ const EditAvailability: React.FC = ({ ); }; + const syncFromExternalCalendar = () => { + const allSlots = enumToArray(REVIEW_TIMES).map((_time, timeIndex) => timeIndex); + let busyCount = 0; + + currentlyDisplayedAvailabilities.forEach((availability) => { + const busySlots = busyByDay.get(availability.dateSet.getTime()) ?? new Set(); + busyCount += busySlots.size; + availability.availability = allSlots.filter((slot) => !busySlots.has(slot)); + editedAvailabilities.set(availability.dateSet.getTime(), availability); + }); + + setEditedAvailabilities(editedAvailabilities); + const currentStartDate = currentlyDisplayedAvailabilities[0]?.dateSet ?? initialDate; + setCurrentlyDisplayedAvailabilities( + getMostRecentAvailabilities(Array.from(editedAvailabilities.values()), currentStartDate) + ); + + toast.success( + busyCount > 0 + ? 'Filled this week from your external calendar — adjust any slots before saving.' + : 'No calendar conflicts found this week — marked you available across the window.' + ); + }; + const toggleTimeSlot = (availability: Availability, selectedTime: number) => { availability.availability.includes(selectedTime) ? availability.availability.splice(availability.availability.indexOf(selectedTime), 1) @@ -133,11 +181,35 @@ const EditAvailability: React.FC = ({ return ( - - Available times in green - - Invert Availability - + + + Available times in green + {showImportedCalendarBusy && ( + + Hatched slots are busy on your imported calendar. Use "Fill from external calendar" to pre-fill, then adjust + any slots manually. + + )} + + + + + + {icsBusyIsFetching ? 'Filling out...' : 'Fill from external calendar'} + + + + + Invert Availability + + = ({ handleMouseDown(e, availability, timeIndex)} onMouseEnter={(e) => handleMouseEnter(e, availability, timeIndex)} onMouseUp={handleMouseUp} diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx index 96cce2872a..4b5378f151 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityModal.tsx @@ -8,6 +8,7 @@ interface SingleAvailabilityModalProps { availabilites: Availability[]; onHide: () => void; initialDate?: Date; + showImportedCalendarBusy?: boolean; } const SingleAvailabilityModal: React.FC = ({ @@ -15,7 +16,8 @@ const SingleAvailabilityModal: React.FC = ({ onHide, header, availabilites, - initialDate + initialDate, + showImportedCalendarBusy }) => { return ( = ({ showCloseButton paperProps={{ maxWidth: '1200px', height: '85vh' }} > - + ); }; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx index 12c9dc7b3c..bf98bbe040 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx @@ -1,17 +1,25 @@ import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; -import { Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; +import { addDaysToDate, Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import NERArrows from '../../../../components/NERArrows'; import { enumToArray, REVIEW_TIMES, getBackgroundColor } from '../../../../utils/design-review.utils'; import EventTimeSlot from '../../../CalendarPage/Components/EventTimeSlot'; +import { useCurrentUser, useUserIcsBusyTimes } from '../../../../hooks/users.hooks'; +import { icsBusySlotsByDay, isSlotBusy } from '../../../../utils/ics.utils'; interface SingleAvailabilityViewProps { totalAvailability: Availability[]; initialDate?: Date; + showImportedCalendarBusy?: boolean; } -const SingleAvailabilityView: React.FC = ({ totalAvailability, initialDate }) => { +const SingleAvailabilityView: React.FC = ({ + totalAvailability, + initialDate, + showImportedCalendarBusy = false +}) => { + const currentUser = useCurrentUser(); const [startDate, setStartDate] = useState(initialDate || new Date()); useEffect(() => { @@ -22,6 +30,14 @@ const SingleAvailabilityView: React.FC = ({ totalAv const selectedTimes = getMostRecentAvailabilities(totalAvailability, startDate); + const weekStart = selectedTimes[0]?.dateSet ?? startDate; + const weekEnd = addDaysToDate(selectedTimes[selectedTimes.length - 1]?.dateSet ?? startDate, 1); + const { data: icsBusy } = useUserIcsBusyTimes(currentUser.userId, weekStart, weekEnd, showImportedCalendarBusy); + const busyByDay = useMemo( + () => (showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>()), + [icsBusy, showImportedCalendarBusy] + ); + const onArrowIncrease = () => { const newDate = new Date(startDate); newDate.setDate(newDate.getDate() + 7); @@ -43,6 +59,12 @@ const SingleAvailabilityView: React.FC = ({ totalAv return ( + {showImportedCalendarBusy && ( + + Hatched slots are busy on your imported calendar. Edit your availability and use "Fill from external calendar" to + pull in any changes. + + )} = ({ totalAv {}} /> diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx index 13470d017e..b2163c8489 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettings.tsx @@ -29,6 +29,7 @@ import { availabilityTransformer } from '../../../apis/transformers/users.transf export interface ScheduleSettingsFormInput { personalGmail?: string; personalZoomLink?: string; + importedIcsCalendarUrl?: string; } export interface ScheduleSettingsPayload extends ScheduleSettingsFormInput { @@ -81,6 +82,7 @@ const UserScheduleSettings = ({ user }: { user: AuthenticatedUser }) => { const defaultValues: SetUserScheduleSettingsArgs = { personalGmail: data.personalGmail, personalZoomLink: data.personalZoomLink, + importedIcsCalendarUrl: data.importedIcsCalendarUrl, availability: getMostRecentAvailabilities(data.availabilities, new Date()) }; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsEdit.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsEdit.tsx index 7e7a91f7f6..707707a9f1 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsEdit.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsEdit.tsx @@ -26,7 +26,19 @@ interface UserScheduleSettingsEditProps { const schema = yup.object().shape({ personalGmail: yup.string().email('Must be an email address').optional(), - personalZoomLink: yup.string().optional() + personalZoomLink: yup.string().optional(), + importedIcsCalendarUrl: yup + .string() + .optional() + .test('is-ics-url', 'Must be a valid http(s):// or webcal:// calendar link', (value) => { + if (!value || !value.trim()) return true; + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:' || protocol === 'webcal:'; + } catch { + return false; + } + }) }); const UserScheduleSettingsEdit: React.FC = ({ @@ -70,7 +82,8 @@ const UserScheduleSettingsEdit: React.FC = ({ resolver: yupResolver(schema), defaultValues: { personalGmail: defaultValues?.personalGmail, - personalZoomLink: defaultValues?.personalZoomLink + personalZoomLink: defaultValues?.personalZoomLink, + importedIcsCalendarUrl: defaultValues?.importedIcsCalendarUrl ?? '' } }); @@ -78,7 +91,8 @@ const UserScheduleSettingsEdit: React.FC = ({ onSubmit({ availability: Array.from(availabilities.values()), personalGmail: watch('personalGmail'), - personalZoomLink: watch('personalZoomLink') + personalZoomLink: watch('personalZoomLink'), + importedIcsCalendarUrl: watch('importedIcsCalendarUrl') }); setEditAvailability(false); }; @@ -95,6 +109,7 @@ const UserScheduleSettingsEdit: React.FC = ({ totalAvailabilities={totalAvailabilities} setConfirmedAvailabilities={setAvailabilities} initialDate={new Date()} + showImportedCalendarBusy={!!defaultValues?.importedIcsCalendarUrl} /> @@ -148,6 +163,41 @@ const UserScheduleSettingsEdit: React.FC = ({ /> + + + + Imported Calendar Link (ICS) + + Find this on Google Calendar: +
+ Settings → "Settings for my calendars" → {'{your calendar name}'} → "Integrate calendar" → "Secret + address in iCal format" + + } + placement="right" + > + +
+
+ ( + + )} + /> +
+
0 ? `${importedIcsCalendarUrl.slice(0, 20)}...` : 'None'; + return ( setAvailabilityOpen(false)} header={'Availability'} availabilites={scheduleSettings.availabilities} + showImportedCalendarBusy={!!scheduleSettings.importedIcsCalendarUrl} /> handleConfirm({ availability: Array.from(confirmedAvailabilities.values()) })} canChangeDateRange={false} + showImportedCalendarBusy={!!scheduleSettings.importedIcsCalendarUrl} /> @@ -90,6 +96,9 @@ const UserScheduleSettingsView = ({ + + + setAvailabilityOpen(true)}> View Availability diff --git a/src/frontend/src/utils/ics.utils.ts b/src/frontend/src/utils/ics.utils.ts new file mode 100644 index 0000000000..10b4f953e7 --- /dev/null +++ b/src/frontend/src/utils/ics.utils.ts @@ -0,0 +1,24 @@ +import { IcsBusySlots } from 'shared'; + +/** + * Builds a lookup of imported-calendar busy availability slots keyed by local-midnight day time, so it + * matches the date produced by availabilityTransformer + * + * @param busy the per-day busy slots returned by the ICS busy-times endpoint (dateSet at UTC midnight) + * @returns a map from local-midnight day time -> set of busy slot indices + */ +export const icsBusySlotsByDay = (busy: IcsBusySlots[]): Map> => { + const map = new Map>(); + busy.forEach((day) => { + const date = new Date(day.dateSet); + const localMidnight = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); + map.set(localMidnight.getTime(), new Set(day.busySlots)); + }); + return map; +}; + +/** + * @returns whether the given availability slot on the given day is busy on the user's imported calendar + */ +export const isSlotBusy = (busyByDay: Map>, dateSet: Date, slot: number): boolean => + busyByDay.get(dateSet.getTime())?.has(slot) ?? false; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index ee39409179..820a6373dc 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,6 +26,7 @@ const userRoleByUserId = (id: string) => `${usersById(id)}/change-role`; const userFavoriteProjects = (id: string) => `${usersById(id)}/favorite-projects`; const userSecureSettings = (id: string) => `${usersById(id)}/secure-settings`; const userScheduleSettings = (id: string) => `${usersById(id)}/schedule-settings`; +const userScheduleSettingsIcsBusy = (id: string) => `${usersById(id)}/schedule-settings/ics-busy`; const userScheduleSettingsSet = () => `${users()}/schedule-settings/set`; const userTasks = (id: string) => `${usersById(id)}/tasks`; const manyUserTasks = () => `${users()}/tasks/get-many`; @@ -526,6 +527,7 @@ export const apiUrls = { userFavoriteProjects, userSecureSettings, userScheduleSettings, + userScheduleSettingsIcsBusy, userScheduleSettingsSet, userTasks, manyUserTasks, diff --git a/yarn.lock b/yarn.lock index 004259ff34..f6ceaf4452 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4714,6 +4714,15 @@ __metadata: languageName: node linkType: hard +"@js-temporal/polyfill@npm:^0.5.1": + version: 0.5.1 + resolution: "@js-temporal/polyfill@npm:0.5.1" + dependencies: + jsbi: ^4.3.0 + checksum: f87127500d4532034d74836160bc013d02989dfe4f67f9264aeca6b2bb1eaed66e1bdb80e37e6e1c647d698726d317459ff13d8b2aa25b107af24c06590e8bd0 + languageName: node + linkType: hard + "@jsonjoy.com/base64@npm:17.67.0": version: 17.67.0 resolution: "@jsonjoy.com/base64@npm:17.67.0" @@ -9273,6 +9282,7 @@ __metadata: ical-generator: ^10.2.0 jsonwebtoken: ^8.5.1 multer: ^1.4.5-lts.1 + node-ical: ^0.26.1 nodemailer: ^6.9.1 nodemon: ^2.0.16 prisma: ^6.2.1 @@ -16965,6 +16975,13 @@ __metadata: languageName: node linkType: hard +"jsbi@npm:^4.3.0": + version: 4.3.2 + resolution: "jsbi@npm:4.3.2" + checksum: 58e06ea3328c1f455ab92219254b4ac09257fee2b611f4af73c0a6606022bbf2f5b9bf1f6f713eda3c943a842454e8ee0948b0525362cb0182e54e851e8d67c4 + languageName: node + linkType: hard + "jsdom@npm:^16.6.0": version: 16.7.0 resolution: "jsdom@npm:16.7.0" @@ -19111,6 +19128,16 @@ __metadata: languageName: node linkType: hard +"node-ical@npm:^0.26.1": + version: 0.26.1 + resolution: "node-ical@npm:0.26.1" + dependencies: + rrule-temporal: ^1.5.3 + temporal-polyfill: ^0.3.2 + checksum: b3c1c546e0c2b20d9850be9bceebd9e563c876205497c74b7f6a8518ce2e1a01e4752bbd2f60c5da37105e2b44cbc1ed2927ff11ad50a55829e5447adb0bfaa9 + languageName: node + linkType: hard + "node-int64@npm:^0.4.0": version: 0.4.0 resolution: "node-int64@npm:0.4.0" @@ -23387,6 +23414,15 @@ __metadata: languageName: node linkType: hard +"rrule-temporal@npm:^1.5.3": + version: 1.5.3 + resolution: "rrule-temporal@npm:1.5.3" + dependencies: + "@js-temporal/polyfill": ^0.5.1 + checksum: a97bec86e1093036632bdb0c90dce2d6b1c7ef4652e5771d70efced38eaa5b96bb6389b212112d9e407931c3a9a6893b19abb03c7a8be73e8ba044a8035075d1 + languageName: node + linkType: hard + "rtlcss@npm:^4.1.0": version: 4.3.0 resolution: "rtlcss@npm:4.3.0" @@ -24953,6 +24989,22 @@ __metadata: languageName: node linkType: hard +"temporal-polyfill@npm:^0.3.2": + version: 0.3.2 + resolution: "temporal-polyfill@npm:0.3.2" + dependencies: + temporal-spec: 0.3.1 + checksum: bd24aa2105e3a4f53fec5d641c4a69f9e0394db04434a83fa1191015b453bc498c21f75bb0cb30a823795db919194ec0e6d7894ae99f03155bec5c7bba1b99f3 + languageName: node + linkType: hard + +"temporal-spec@npm:0.3.1": + version: 0.3.1 + resolution: "temporal-spec@npm:0.3.1" + checksum: f226a51169e86297a34fbfad3fefceab4a9581744f0e03f45d6d8112b02670d9972a0318edf05ab3500516cb062a331335a579fc2b94ea440f8a6aafa4e4eec2 + languageName: node + linkType: hard + "tempy@npm:^0.6.0": version: 0.6.0 resolution: "tempy@npm:0.6.0" From 7f2c5fc6b76d783f478b6c2dac6d6c25eef0af5d Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:55:04 -0400 Subject: [PATCH 2/7] #4232 import gcal availailability --- src/shared/src/types/calendar-types.ts | 10 ++++++++++ src/shared/src/types/user-types.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts index de8c7d5229..a14514c2f5 100644 --- a/src/shared/src/types/calendar-types.ts +++ b/src/shared/src/types/calendar-types.ts @@ -272,3 +272,13 @@ export interface AvailabilityCreateArgs { availability: number[]; dateSet: Date; } + +export interface IcsBusyInterval { + start: Date; + end: Date; +} + +export interface IcsBusySlots { + dateSet: Date; + busySlots: number[]; +} diff --git a/src/shared/src/types/user-types.ts b/src/shared/src/types/user-types.ts index 24cc141eac..e06be5f84d 100644 --- a/src/shared/src/types/user-types.ts +++ b/src/shared/src/types/user-types.ts @@ -117,6 +117,7 @@ export interface UserScheduleSettings { personalGmail: string; personalZoomLink: string; availabilities: Availability[]; + importedIcsCalendarUrl: string; } export interface Availability { @@ -132,6 +133,7 @@ export interface SetUserScheduleSettingsArgs { personalGmail?: string; personalZoomLink?: string; availability: AvailabilityCreateArgs[]; + importedIcsCalendarUrl?: string; } export interface SetUserScheduleSettingsPayload extends SetUserScheduleSettingsArgs { From 33f4c9d58c2247a1ba643e6dcb73064f4f8cbf51 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:12:06 -0400 Subject: [PATCH 3/7] #4232 fix test data --- src/backend/tests/test-data/users.test-data.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backend/tests/test-data/users.test-data.ts b/src/backend/tests/test-data/users.test-data.ts index 848111b971..6a09c9b055 100644 --- a/src/backend/tests/test-data/users.test-data.ts +++ b/src/backend/tests/test-data/users.test-data.ts @@ -173,7 +173,8 @@ export const batmanScheduleSettings: Schedule_Settings = { drScheduleSettingsId: 'bmschedule', personalGmail: 'brucewayne@gmail.com', personalZoomLink: 'https://zoom.us/j/gotham', - userId: '69' + userId: '69', + importedIcsCalendarUrl: '' }; export const batmanWithScheduleSettings: CreateTestUserParams & { scheduleSettings: Schedule_Settings } = { @@ -187,21 +188,24 @@ export const batmanUserScheduleSettings: UserScheduleSettings = { drScheduleSettingsId: 'bmschedule', personalGmail: 'brucewayne@gmail.com', personalZoomLink: 'https://zoom.us/j/gotham', - availabilities: [] + availabilities: [], + importedIcsCalendarUrl: '' }; export const wonderwomanScheduleSettings: Schedule_Settings = { drScheduleSettingsId: 'wwschedule', personalGmail: 'diana@gmail.com', personalZoomLink: 'https://zoom.us/jk/athens', - userId: '72' + userId: '72', + importedIcsCalendarUrl: '' }; export const wonderwomanMarkedScheduleSettings: Schedule_Settings = { drScheduleSettingsId: 'wwschedule', personalGmail: 'diana@gmail.com', personalZoomLink: 'https://zoom.us/jk/athens', - userId: '72' + userId: '72', + importedIcsCalendarUrl: '' }; export const wonderwomanWithScheduleSettings: CreateTestUserParams & { scheduleSettings: Schedule_Settings } = { From 974bbb9147e3638adb168b90af6768289765cce1 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:20:42 -0400 Subject: [PATCH 4/7] #4232 fixes --- src/backend/src/controllers/users.controllers.ts | 3 ++- src/backend/src/services/users.services.ts | 13 ++++++++----- src/backend/src/utils/ics.utils.ts | 8 ++++++-- .../Availability/EditAvailability.tsx | 5 +---- .../Availability/SingleAvailabilityView.tsx | 5 +---- .../UserScheduleSettingsView.tsx | 4 ++-- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index fd92225a98..94eb37f583 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -209,7 +209,8 @@ export default class UsersController { userId, req.currentUser, new Date(startDate), - new Date(endDate) + new Date(endDate), + req.organization ); res.status(200).json(busyTimes); } catch (error: unknown) { diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 90fd984e81..13f0e0f878 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -638,15 +638,21 @@ export default class UsersService { * @param submitter the requesting user * @param startDate the first day of the range (inclusive) * @param endDate the day after the last day of the range (exclusive) + * @param organization the organization the requesting user is in * @returns the busy slots per day, only including days that have at least one busy slot */ static async getUserIcsBusyTimes( userId: string, submitter: User, startDate: Date, - endDate: Date + endDate: Date, + organization: Organization ): Promise { if (submitter.userId !== userId) throw new AccessDeniedException('You can only access your own schedule settings'); + const user = await prisma.user.findUnique({ where: { userId }, include: { organizations: true } }); + if (!user) throw new NotFoundException('User', userId); + if (!user.organizations.map((org) => org.organizationId).includes(organization.organizationId)) + throw new HttpException(400, `User ${userId} is not apart of the current organization`); const scheduleSettings = await prisma.schedule_Settings.findUnique({ where: { userId } }); if (!scheduleSettings?.importedIcsCalendarUrl) return []; @@ -656,10 +662,7 @@ export default class UsersService { busy = await fetchIcsBusyTimes(scheduleSettings.importedIcsCalendarUrl, startDate, endDate); } catch (error) { if (error instanceof HttpException) { - console.error( - `Failed to fetch ICS busy-times for schedule settings ${scheduleSettings.drScheduleSettingsId}: ${error.message}` - ); - return []; + throw new HttpException(error.status, `Failed to fetch ICS calendar: ${error.message}`); } throw error; } diff --git a/src/backend/src/utils/ics.utils.ts b/src/backend/src/utils/ics.utils.ts index 52195c16fe..fd6ac8ea6b 100644 --- a/src/backend/src/utils/ics.utils.ts +++ b/src/backend/src/utils/ics.utils.ts @@ -56,7 +56,11 @@ const isBlockedHost = (host: string): boolean => { /^10\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(h) || - /^169\.254\./.test(h) + /^169\.254\./.test(h) || + h.startsWith('::ffff:') || + /^fe80:/i.test(h) || + /^fc[0-9a-f]{2}:/i.test(h) || + /^fd[0-9a-f]{2}:/i.test(h) ); }; @@ -86,7 +90,7 @@ const fetchIcsText = async (url: URL): Promise => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); try { - const res = await fetch(url, { signal: controller.signal, redirect: 'follow' }); + const res = await fetch(url, { signal: controller.signal, redirect: 'error' }); if (!res.ok) throw new HttpException(502, `Failed to fetch ICS feed (status ${res.status})`); return await res.text(); } catch (err) { diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx index c9d0a2bad0..a03be6f112 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx @@ -69,10 +69,7 @@ const EditAvailability: React.FC = ({ showImportedCalendarBusy ); - const busyByDay = useMemo( - () => (showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>()), - [icsBusy, showImportedCalendarBusy] - ); + const busyByDay = showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>(); const handleMouseDown = (event: any, availability: Availability, selectedTime: number) => { event.preventDefault(); diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx index bf98bbe040..d8e5519686 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx @@ -33,10 +33,7 @@ const SingleAvailabilityView: React.FC = ({ const weekStart = selectedTimes[0]?.dateSet ?? startDate; const weekEnd = addDaysToDate(selectedTimes[selectedTimes.length - 1]?.dateSet ?? startDate, 1); const { data: icsBusy } = useUserIcsBusyTimes(currentUser.userId, weekStart, weekEnd, showImportedCalendarBusy); - const busyByDay = useMemo( - () => (showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>()), - [icsBusy, showImportedCalendarBusy] - ); + const busyByDay = showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>(); const onArrowIncrease = () => { const newDate = new Date(startDate); diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsView.tsx index aa88cc08bb..c0be1bcd0c 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/UserScheduleSettingsView.tsx @@ -76,7 +76,7 @@ const UserScheduleSettingsView = ({ onHide={() => setAvailabilityOpen(false)} header={'Availability'} availabilites={scheduleSettings.availabilities} - showImportedCalendarBusy={!!scheduleSettings.importedIcsCalendarUrl} + showImportedCalendarBusy={!!importedIcsCalendarUrl} /> handleConfirm({ availability: Array.from(confirmedAvailabilities.values()) })} canChangeDateRange={false} - showImportedCalendarBusy={!!scheduleSettings.importedIcsCalendarUrl} + showImportedCalendarBusy={!!importedIcsCalendarUrl} /> From e6d6866427e109538652ec9e569bf5feaa709f8c Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:22:49 -0400 Subject: [PATCH 5/7] #4232 fix lint --- .../UserScheduleSettings/Availability/EditAvailability.tsx | 2 +- .../Availability/SingleAvailabilityView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx index a03be6f112..70f76ce24c 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx @@ -10,7 +10,7 @@ import { Typography, useMediaQuery } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { HeatmapColors, enumToArray, REVIEW_TIMES } from '../../../../utils/design-review.utils'; import { addDaysToDate, Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; diff --git a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx index d8e5519686..310dc87608 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/SingleAvailabilityView.tsx @@ -1,7 +1,7 @@ import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; import { addDaysToDate, Availability, getDayOfWeek, getMostRecentAvailabilities } from 'shared'; import { datePipe } from '../../../../utils/pipes'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import NERArrows from '../../../../components/NERArrows'; import { enumToArray, REVIEW_TIMES, getBackgroundColor } from '../../../../utils/design-review.utils'; import EventTimeSlot from '../../../CalendarPage/Components/EventTimeSlot'; From ac51b1511b0bcc4289216bdb6f654da98ed0283b Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:11:11 -0400 Subject: [PATCH 6/7] #4232 encrypt and add type to ics utils --- src/backend/src/services/users.services.ts | 9 ++++--- .../user-schedule-settings.transformer.ts | 3 ++- src/backend/src/utils/ics.utils.ts | 27 +++++++++++++------ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 13f0e0f878..db23ca3ef9 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -31,6 +31,7 @@ import authenticatedUserTransformer from '../transformers/auth-user.transformer. import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args.js'; import taskTransformer from '../transformers/tasks.transformer.js'; import { validateUserIsPartOfFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; +import { encrypt, decrypt } from '../utils/encryption.utils.js'; export default class UsersService { /** @@ -542,18 +543,20 @@ export default class UsersService { if (importedIcsCalendarUrl) validateIcsUrl(importedIcsCalendarUrl); + const encryptedIcsUrl = importedIcsCalendarUrl ? encrypt(importedIcsCalendarUrl) : importedIcsCalendarUrl; + const newUserScheduleSettings = await prisma.schedule_Settings.upsert({ where: { userId: user.userId }, update: { personalGmail, personalZoomLink, - importedIcsCalendarUrl + importedIcsCalendarUrl: encryptedIcsUrl }, create: { userId: user.userId, personalGmail, personalZoomLink, - importedIcsCalendarUrl + importedIcsCalendarUrl: encryptedIcsUrl }, ...getUserScheduleSettingsQueryArgs() }); @@ -659,7 +662,7 @@ export default class UsersService { let busy; try { - busy = await fetchIcsBusyTimes(scheduleSettings.importedIcsCalendarUrl, startDate, endDate); + busy = await fetchIcsBusyTimes(decrypt(scheduleSettings.importedIcsCalendarUrl), startDate, endDate); } catch (error) { if (error instanceof HttpException) { throw new HttpException(error.status, `Failed to fetch ICS calendar: ${error.message}`); diff --git a/src/backend/src/transformers/user-schedule-settings.transformer.ts b/src/backend/src/transformers/user-schedule-settings.transformer.ts index af10bdd196..3814b912bc 100644 --- a/src/backend/src/transformers/user-schedule-settings.transformer.ts +++ b/src/backend/src/transformers/user-schedule-settings.transformer.ts @@ -1,6 +1,7 @@ import { Prisma } from '@prisma/client'; import { UserScheduleSettings } from 'shared'; import { UserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args.js'; +import { decrypt } from '../utils/encryption.utils.js'; const userScheduleSettingsTransformer = ( settings: Prisma.Schedule_SettingsGetPayload @@ -10,7 +11,7 @@ const userScheduleSettingsTransformer = ( personalGmail: settings.personalGmail, personalZoomLink: settings.personalZoomLink, availabilities: settings.availabilities, - importedIcsCalendarUrl: settings.importedIcsCalendarUrl + importedIcsCalendarUrl: settings.importedIcsCalendarUrl ? decrypt(settings.importedIcsCalendarUrl) : settings.importedIcsCalendarUrl }; }; diff --git a/src/backend/src/utils/ics.utils.ts b/src/backend/src/utils/ics.utils.ts index fd6ac8ea6b..30dec0879b 100644 --- a/src/backend/src/utils/ics.utils.ts +++ b/src/backend/src/utils/ics.utils.ts @@ -1,5 +1,5 @@ import ical, { ICalEventStatus } from 'ical-generator'; -import nodeIcal, { CalendarComponent, VEvent } from 'node-ical'; +import nodeIcal, { CalendarComponent, RRule, VEvent } from 'node-ical'; import { IcsBusyInterval, Event, wbsPipe } from 'shared'; import { HttpException } from './errors.utils.js'; @@ -44,6 +44,17 @@ export const generateIcsFeed = (events: Event[]): string => { return cal.toString(); }; +interface VEventWithExtras extends Omit { + rrule?: Pick; + transparency?: string; + exdate?: Record; + recurrences?: Record; +} + +interface VEventRecurrenceOverride extends VEvent { + recurrenceid?: Date; +} + // checks if a given host is blocked (used to mitigate ssrf attacks) const isBlockedHost = (host: string): boolean => { const h = host.toLowerCase(); @@ -124,16 +135,16 @@ export const fetchIcsBusyTimes = async (url: string, rangeStart: Date, rangeEnd: for (const component of Object.values(parsed)) { if (!component || component.type !== 'VEVENT') continue; - const ev = component as VEvent; + const ev = component as VEventWithExtras; if (ev.status === 'CANCELLED') continue; - if ((ev as unknown as { transparency?: string }).transparency === 'TRANSPARENT') continue; + if (ev.transparency === 'TRANSPARENT') continue; const baseStart = ev.start as Date | undefined; const baseEnd = ev.end as Date | undefined; if (!baseStart || !baseEnd) continue; - const { rrule } = ev as unknown as { rrule?: { between: (a: Date, b: Date, inc: boolean) => Date[] } }; + const { rrule } = ev; if (!rrule) { if (baseEnd > rangeStart && baseStart < rangeEnd) { @@ -145,13 +156,13 @@ export const fetchIcsBusyTimes = async (url: string, rangeStart: Date, rangeEnd: const durationMs = baseEnd.getTime() - baseStart.getTime(); const occurrences = rrule.between(rangeStart, rangeEnd, true); - const exdateMap = (ev as unknown as { exdate?: Record }).exdate ?? {}; + const exdateMap = ev.exdate ?? {}; const exdateTimes = new Set(Object.values(exdateMap).map((d) => d.getTime())); - const recurrenceMap = (ev as unknown as { recurrences?: Record }).recurrences ?? {}; - const recurrencesByTime = new Map(); + const recurrenceMap = ev.recurrences ?? {}; + const recurrencesByTime = new Map(); for (const override of Object.values(recurrenceMap)) { - const recId = (override as unknown as { recurrenceid?: Date }).recurrenceid ?? (override.start as Date); + const recId = override.recurrenceid ?? (override.start as Date); if (recId) recurrencesByTime.set(recId.getTime(), override); } From ab481604beeea5e43d517c449e2fc276d8b414e4 Mon Sep 17 00:00:00 2001 From: Sarah Taylor <150694563+staysgt@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:14:15 -0400 Subject: [PATCH 7/7] #4232 prettier --- .../src/transformers/user-schedule-settings.transformer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/src/transformers/user-schedule-settings.transformer.ts b/src/backend/src/transformers/user-schedule-settings.transformer.ts index 3814b912bc..3aa7d686d5 100644 --- a/src/backend/src/transformers/user-schedule-settings.transformer.ts +++ b/src/backend/src/transformers/user-schedule-settings.transformer.ts @@ -11,7 +11,9 @@ const userScheduleSettingsTransformer = ( personalGmail: settings.personalGmail, personalZoomLink: settings.personalZoomLink, availabilities: settings.availabilities, - importedIcsCalendarUrl: settings.importedIcsCalendarUrl ? decrypt(settings.importedIcsCalendarUrl) : settings.importedIcsCalendarUrl + importedIcsCalendarUrl: settings.importedIcsCalendarUrl + ? decrypt(settings.importedIcsCalendarUrl) + : settings.importedIcsCalendarUrl }; };