diff --git a/src/backend/package.json b/src/backend/package.json index 67a6f90539..974a208675 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..94eb37f583 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,24 @@ 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), + req.organization + ); + 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..db23ca3ef9 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'; @@ -29,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 { /** @@ -525,7 +528,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 +541,22 @@ 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 + personalZoomLink, + importedIcsCalendarUrl: encryptedIcsUrl }, create: { userId: user.userId, personalGmail, - personalZoomLink + personalZoomLink, + importedIcsCalendarUrl: encryptedIcsUrl }, ...getUserScheduleSettingsQueryArgs() }); @@ -622,4 +632,52 @@ 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) + * @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, + 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 []; + + let busy; + try { + 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}`); + } + 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..3aa7d686d5 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 @@ -9,7 +10,10 @@ const userScheduleSettingsTransformer = ( drScheduleSettingsId: settings.drScheduleSettingsId, personalGmail: settings.personalGmail, personalZoomLink: settings.personalZoomLink, - availabilities: settings.availabilities + availabilities: settings.availabilities, + 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 f2c79e2988..30dec0879b 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, RRule, 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,182 @@ 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(); + 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) || + h.startsWith('::ffff:') || + /^fe80:/i.test(h) || + /^fc[0-9a-f]{2}:/i.test(h) || + /^fd[0-9a-f]{2}:/i.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: 'error' }); + 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 VEventWithExtras; + + if (ev.status === 'CANCELLED') 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; + + 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.exdate ?? {}; + const exdateTimes = new Set(Object.values(exdateMap).map((d) => d.getTime())); + + const recurrenceMap = ev.recurrences ?? {}; + const recurrencesByTime = new Map(); + for (const override of Object.values(recurrenceMap)) { + const recId = override.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/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 } = { 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..70f76ce24c 100644 --- a/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx +++ b/src/frontend/src/pages/SettingsPage/UserScheduleSettings/Availability/EditAvailability.tsx @@ -6,6 +6,7 @@ import { TableContainer, TableHead, TableRow, + Tooltip, Typography, useMediaQuery } from '@mui/material'; @@ -16,6 +17,9 @@ 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,20 @@ 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 = showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>(); + const handleMouseDown = (event: any, availability: Availability, selectedTime: number) => { event.preventDefault(); toggleTimeSlot(availability, selectedTime); @@ -108,6 +129,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 +178,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..310dc87608 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 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,11 @@ 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 = showImportedCalendarBusy ? icsBusySlotsByDay(icsBusy ?? []) : new Map>(); + const onArrowIncrease = () => { const newDate = new Date(startDate); newDate.setDate(newDate.getDate() + 7); @@ -43,6 +56,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={!!importedIcsCalendarUrl} /> handleConfirm({ availability: Array.from(confirmedAvailabilities.values()) })} canChangeDateRange={false} + showImportedCalendarBusy={!!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/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts index eb49c76548..d9d88d8297 100644 --- a/src/shared/src/types/calendar-types.ts +++ b/src/shared/src/types/calendar-types.ts @@ -277,3 +277,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 { diff --git a/yarn.lock b/yarn.lock index b71783193e..1982b18619 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: "npm:^4.3.0" + checksum: 10/6c3d51e929269a7ab791766edf32b9a7c9bc45407e1708675505d5dcd455839704f189c39b99e993d0f9fcb987e57a2e78c78b4483128b24fe5c7a7e29cc4406 + 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: "npm:^10.2.0" jsonwebtoken: "npm:^8.5.1" multer: "npm:^1.4.5-lts.1" + node-ical: "npm:^0.26.1" nodemailer: "npm:^6.9.1" nodemon: "npm:^2.0.16" prisma: "npm:^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: 10/71f2d292ba8db5fa68d3f7a2427664a356065adb629a35404635ccd70ed412631a301b910613323de14b3310dab71a6b8c4ecf0f9c0653ee61c22e94679d52f3 + 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: "npm:^1.5.3" + temporal-polyfill: "npm:^0.3.2" + checksum: 10/58ab31f91de887d7605755f7fc430b4c1dccab7b99777f18a2e73584ed9b7230f3d67f912f15e1f137c1bcc09178ef4a23651d06979be0c209de0315ad2099c2 + 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": "npm:^0.5.1" + checksum: 10/f85effb665471ad57df25718ed1664879f489c8eadb795701254c5d431e32f4a6bc4b3002a04787f3da6968c1b08f359f3af81939844ffea08fff69671529409 + 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: "npm:0.3.1" + checksum: 10/3e4ced86e08e7893286b9471f28baacc7c1d29a854372405d81c490eaa3eaed860d2e1aa25094f110ff5edc5a9d932c7957a723004ac0a4f145e8045fa387fc0 + languageName: node + linkType: hard + +"temporal-spec@npm:0.3.1": + version: 0.3.1 + resolution: "temporal-spec@npm:0.3.1" + checksum: 10/69509d1a4162c834b76d1394a3a97cef57df9ec912b8aa549a989be8bd75f25a72c987fcc7747d566298b73d4264be9e48a8d0a91e60edcb71c7901aabfb00e7 + languageName: node + linkType: hard + "tempy@npm:^0.6.0": version: 0.6.0 resolution: "tempy@npm:0.6.0"