diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index d9ec720652..f6f61b87fa 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -645,4 +645,15 @@ export default class CalendarController { next(error); } } + + static async remindUnconfirmed(req: Request, res: Response, next: NextFunction) { + try { + const { eventId } = req.params as Record; + + await CalendarService.remindUnconfirmed(eventId, req.currentUser, req.organization); + res.status(200).json({ message: 'Reminders sent to unconfirmed members' }); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 228f61b04a..1431df39de 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -305,4 +305,6 @@ calendarRouter.get('/calendars', CalendarController.getAllCalendars); calendarRouter.post('/events-paginated', CalendarController.getAllEventsPaginated); calendarRouter.get('/ics/token', CalendarController.getOrCreateIcsToken); +calendarRouter.post('/event/:eventId/remind-unconfirmed', CalendarController.remindUnconfirmed); + export default calendarRouter; diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index dfc2387ece..bb86368941 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -2981,4 +2981,43 @@ export default class CalendarService { return events.map(eventTransformer); } + + static async remindUnconfirmed(eventId: string, submitter: User, organization: Organization): Promise { + const event = await prisma.event.findUnique({ + where: { eventId }, + ...getEventWithMembersQueryArgs(organization.organizationId) + }); + + if (!event) throw new NotFoundException('Event', eventId); + if (event.dateDeleted) throw new DeletedException('Event', eventId); + + const hasPermission = + (await userHasPermission(submitter.userId, organization.organizationId, isAdmin)) || + submitter.userId === event.userCreatedId; + + if (!hasPermission) { + throw new AccessDeniedException('Only the creator or an admin can send reminders for unconfirmed events'); + } + + const confirmedMemberIds = new Set(event.confirmedMembers.map((u: { userId: string }) => u.userId)); + + const allMembers = [...event.requiredMembers, ...event.optionalMembers]; + const unconfirmedMembers = allMembers.filter((u: { userId: string }) => !confirmedMemberIds.has(u.userId)); + const uniqueUnconfirmedIds = [...new Set(unconfirmedMembers.map((u: { userId: string }) => u.userId))]; + + if (uniqueUnconfirmedIds.length === 0) return; + + const memberUserSettings = await prisma.user_Settings.findMany({ + where: { userId: { in: uniqueUnconfirmedIds } } + }); + + const projects = event.workPackages.map((wp: { project: { wbsElement: { name: string } } }) => wp.project); + const projectName = projects.map((p: { wbsElement: { name: string } }) => p.wbsElement.name).join(', '); + + for (const userSetting of memberUserSettings) { + if (userSetting.slackId) { + await sendSlackEventConfirmNotification(userSetting.slackId, eventId, event.title, projectName, true); + } + } + } } diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index 8036985646..0df13b2a09 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -314,11 +314,14 @@ export const sendSlackEventConfirmNotification = async ( slackId: string, eventId: string, eventName: string, - projectName: string + projectName: string, + remindUnconfirmed: boolean = false ) => { const isProduction = process.env.NODE_ENV === 'production'; if (!isProduction && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod - const msg = `You have been invited to ${eventName} in project ${projectName}!`; + const msg = remindUnconfirmed + ? `REMINDER: Please fill out your availability for ${eventName} in project ${projectName}!` + : `You have been invited to ${eventName} in project ${projectName}!`; const fullLink = isProduction ? `https://finishlinebyner.com/calendar/event/${eventId}` : `http://localhost:3000/calendar/event/${eventId}`; diff --git a/src/backend/tests/unit/calendar.test.ts b/src/backend/tests/unit/calendar.test.ts index 4f3b6cb8b9..d15b266f11 100644 --- a/src/backend/tests/unit/calendar.test.ts +++ b/src/backend/tests/unit/calendar.test.ts @@ -2612,4 +2612,68 @@ describe('Calendar Tests', () => { expect(result.find((s) => s.scheduleSlotId === slotId)).toBeUndefined(); }); }); + + describe('remindUnconfirmed', () => { + let member: User; + let event: Event; + + beforeEach(async () => { + member = await createTestUser(wonderwomanGuest, orgId); + const scheduleSlots: ScheduleSlotCreateArgs[] = [ + { startTime: new Date('2025-10-13T09:00:00Z'), endTime: new Date('2025-10-13T10:00:00Z'), allDay: false } + ]; + event = await CalendarService.createEvent( + adminUser, + 'Test Event', + eventType.eventTypeId, + organization, + [member.userId], + [], + [], + [], + [], + [], + scheduleSlots, + undefined, // initialDateScheduled + [], // notificationChannelIds + undefined, // teamTypeId + undefined, // questionDocumentLink + 'Conference Room A', // location + undefined, // zoomLink + undefined // description + ); + }); + + it('fails if event does not exist', async () => { + await expect(CalendarService.remindUnconfirmed('non-existent-id', adminUser, organization)).rejects.toThrow( + new NotFoundException('Event', 'non-existent-id') + ); + }); + + it('fails if event is already deleted', async () => { + await prisma.event.update({ where: { eventId: event.eventId }, data: { dateDeleted: new Date() } }); + await expect(CalendarService.remindUnconfirmed(event.eventId, adminUser, organization)).rejects.toThrow( + new DeletedException('Event', event.eventId) + ); + }); + + it('fails if submitter is not the creator', async () => { + await expect(CalendarService.remindUnconfirmed(event.eventId, member, organization)).rejects.toThrow( + new AccessDeniedException('Only the creator or an admin can send reminders for unconfirmed events') + ); + }); + + it('succeeds and sends reminders to unconfirmed members', async () => { + await expect(CalendarService.remindUnconfirmed(event.eventId, adminUser, organization)).resolves.toBeUndefined(); + }); + + it('succeeds when all members have already confirmed', async () => { + // pre-confirm the member so there are no unconfirmed recipients + await prisma.event.update({ + where: { eventId: event.eventId }, + data: { confirmedMembers: { connect: { userId: member.userId } } } + }); + await expect(CalendarService.remindUnconfirmed(event.eventId, adminUser, organization)).resolves.toBeUndefined(); + }); + }); }); diff --git a/src/frontend/src/apis/calendar.api.ts b/src/frontend/src/apis/calendar.api.ts index f01648f217..782dc185aa 100644 --- a/src/frontend/src/apis/calendar.api.ts +++ b/src/frontend/src/apis/calendar.api.ts @@ -125,6 +125,10 @@ export const markUserConfirmed = async (id: string, payload: { availability: Ava }); }; +export const remindUnconfirmed = async (id: string) => { + return axios.post(apiUrls.calendarEventRemindUnconfirmed(id)); +}; + export const getSingleEvent = async (id: string) => { return axios.get(apiUrls.calendarGetSingleEvent(id), { transformResponse: (data) => eventTransformer(JSON.parse(data)) diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 7577d5d483..985f99b1f4 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -52,7 +52,8 @@ import { postDeleteScheduleSlot, scheduleEvent, getAllEventsPaginated, - getIcsToken + getIcsToken, + remindUnconfirmed } from '../apis/calendar.api'; import { useCurrentUser } from './users.hooks'; import { PDFDocument } from 'pdf-lib'; @@ -338,6 +339,21 @@ export const useMarkUserConfirmed = (id: string) => { ); }; +export const useRemindUnconfirmed = (id: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['events', 'remind-unconfirmed'], + async () => { + await remindUnconfirmed(id); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(EVENT_KEY); + } + } + ); +}; + export const useDeleteCalendar = () => { const qc = useQueryClient(); return useMutation<{ calendarId: string }, Error, string>( diff --git a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx index 3d2d50b909..1a2f6c61ca 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventAvailabilityPage.tsx @@ -20,11 +20,12 @@ import PageLayout from '../../../components/PageLayout'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { useCurrentUser, useUserScheduleSettings, useManyUsersWithScheduleSettings } from '../../../hooks/users.hooks'; -import { useMarkUserConfirmed, useSingleEventWithMembers } from '../../../hooks/calendar.hooks'; +import { useMarkUserConfirmed, useRemindUnconfirmed, useSingleEventWithMembers } from '../../../hooks/calendar.hooks'; import { useParams, useHistory } from 'react-router-dom'; import { eventNamePipe, fullNamePipe } from '../../../utils/pipes'; import NERSuccessButton from '../../../components/NERSuccessButton'; import NERFailButton from '../../../components/NERFailButton'; +import NERModal from '../../../components/NERModal'; import { routes } from '../../../utils/routes'; import { useToast } from '../../../hooks/toasts.hooks'; import { deeplyCopy } from 'shared/src/utils'; @@ -86,6 +87,7 @@ export const EventAvailabilityPage: React.FC = () => { ); const [scheduleModalOpen, setScheduleModalOpen] = useState(false); const [selectedSlot, setSelectedSlot] = useState<{ day: Date; startHour: number; endHour: number } | null>(null); + const [confirmRemindOpen, setConfirmRemindOpen] = useState(false); const { data: event, @@ -125,6 +127,7 @@ export const EventAvailabilityPage: React.FC = () => { } = useManyUsersWithScheduleSettings(allRelevantUserIds); const { mutateAsync: markUserConfirmed } = useMarkUserConfirmed(eventId); + const { mutateAsync: remindUnconfirmed, isLoading: isReminding } = useRemindUnconfirmed(eventId); const displayDate = useMemo(() => { if (dateParam) { @@ -211,6 +214,18 @@ export const EventAvailabilityPage: React.FC = () => { history.push(routes.CALENDAR); }; + const handleRemindConfirm = async () => { + try { + await remindUnconfirmed(); + toast.success('Reminders sent to unconfirmed members!'); + setConfirmRemindOpen(false); + } catch (e) { + if (e instanceof Error) { + toast.error(e.message); + } + } + }; + const availableUsers = new Map(); const unavailableUsers = new Map(); const usersToAvailabilities = new Map(); @@ -412,6 +427,16 @@ export const EventAvailabilityPage: React.FC = () => { )} + + {/* Button to renotify unconfirmed members */} + {(isCreator || isAdmin(currentUser.role)) && ( + + setConfirmRemindOpen(true)}> + {isReminding ? 'Sending Reminders...' : 'Remind Unconfirmed'} + + + )} + {/* Schedule button for creators - only show if event is not already scheduled */} {(isCreator || isAdmin(currentUser.role)) && selectedSlot && ( @@ -455,6 +480,19 @@ export const EventAvailabilityPage: React.FC = () => { beingRescheduled={event.status === EventStatus.SCHEDULED} /> )} + setConfirmRemindOpen(false)} + title="Remind Unconfirmed Members" + onSubmit={handleRemindConfirm} + submitText="Send" + cancelText="Cancel" + disabled={isReminding} + > + + Are you sure you want to send a Slack reminder to all members who have not yet confirmed their availability? + + ); }; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 1305b98b8d..f07046109d 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -482,6 +482,7 @@ const calendarCreateEventType = () => `${calendar()}/event-type/create`; const calendarEditEventType = (eventTypeId: string) => `${calendar()}/event-type/${eventTypeId}/edit`; const calendarDeleteEventType = (eventTypeId: string) => `${calendar()}/event-type/${eventTypeId}/delete`; const calendarEventMarkUserConfirmed = (id: string) => `${calendar()}/event/${id}/confirm-schedule`; +const calendarEventRemindUnconfirmed = (id: string) => `${calendar()}/event/${id}/remind-unconfirmed`; const calendarGetSingleEvent = (id: string) => `${calendar()}/event/${id}`; const calendarGetSingleEventWithMembers = (id: string) => `${calendar()}/event-members/${id}`; const calendarGetConflictingEvent = (id: string) => `${calendar()}/event/${id}/conflict`; @@ -879,6 +880,7 @@ export const apiUrls = { calendarAddMachineryToShop, calendarEditShop, calendarEventMarkUserConfirmed, + calendarEventRemindUnconfirmed, calendarGetSingleEvent, calendarGetSingleEventWithMembers, calendarGetConflictingEvent,