diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index d75cad9b40..925b14fcc9 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -279,7 +279,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention } = req.body; const parsedScheduleSlots = scheduleSlots.map((slot: any) => ({ @@ -307,7 +308,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention ); res.status(200).json(event); } catch (error: unknown) { diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 571ae2afaf..4a34817be0 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -119,6 +119,7 @@ calendarRouter.post( isDate(body('scheduleSlots.*.startTime')), isDate(body('scheduleSlots.*.endTime')), body('scheduleSlots.*.allDay').isBoolean(), + body('mention').isIn(['USER', 'CHANNEL']), validateInputs, CalendarController.createEvent ); diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index d625ca051a..b9065fb6d7 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -16,7 +16,8 @@ import { ScheduleSlot, notGuest, isSameDay, - EventInstance + EventInstance, + SlackMentionType } from 'shared'; import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; @@ -270,7 +271,8 @@ export default class CalendarService { questionDocumentLink?: string, location?: string, zoomLink?: string, - description?: string + description?: string, + mention?: SlackMentionType ): Promise { // Validate eventTypeId const foundEventType = await prisma.event_Type.findUnique({ @@ -541,7 +543,8 @@ export default class CalendarService { createdEvent, submitter, workPackageNames, - organization.name + organization.name, + { memberSlackIds: memberUserSettings.map((s) => s.slackId).filter((id): id is string => !!id), mention } ); } diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index b85a4ca721..1148d63bd3 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -7,7 +7,8 @@ import { CreateSponsorTask, User, Event, - formatForSlack + formatForSlack, + SlackMentionType } from 'shared'; import { Account_Code, Reimbursement_Product_Other_Reason, Sponsor_Task } from '@prisma/client'; import { @@ -383,35 +384,49 @@ export const sendAndGetSlackCRNotifications = async ( return notifications; }; +export const buildSlackMentionPrefix = (mention: SlackMentionType, memberSlackIds: string[]): string => { + if (mention === SlackMentionType.CHANNEL) return ' '; + if (memberSlackIds.length > 0) return `${memberSlackIds.map((id) => `<@${id}>`).join(' ')} `; + return ''; +}; + export const sendSlackEventNotification = async ( team: Team, message: string ): Promise<{ channelId: string; ts: string }[]> => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return []; // don't send msgs unless in prod const msgs: { channelId: string; ts: string }[] = []; - const fullMsg = `${message}`; const fullLink = `https://finishlinebyner.com/calendar`; const btnText = `View Calendar`; - const notification = await sendMessage(team.slackId, fullMsg, fullLink, btnText); + const notification = await sendMessage(team.slackId, message, fullLink, btnText); if (notification) msgs.push(notification); return msgs; }; +export interface EventNotificationOptions { + memberSlackIds?: string[]; + mention?: SlackMentionType; +} + export const sendSlackEventNotifications = async ( teams: Team[], event: Event, submitter: User, workPackageName: string, - projectName: string + projectName: string, + options: EventNotificationOptions = {} ) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return []; // don't send msgs unless in prod const notifications: { channelId: string; ts: string }[] = []; + + const mentionPrefix = buildSlackMentionPrefix(options.mention ?? SlackMentionType.USER, options.memberSlackIds ?? []); + let message; if (workPackageName) { - message = `:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; } else { - message = `:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; } const completion: Promise[] = teams.map(async (team) => { @@ -487,9 +502,14 @@ export const sendEventScheduledSlackNotif = async (threads: SlackMessageThread[] const location = zoomLink && inPersonLocation ? `${inPersonLocation} and ${zoomLink}` : inPersonLocation || zoomLink || ''; + const allMembers = [...event.requiredMembers, ...event.optionalMembers]; + const resolvedSlackIds = await Promise.all(allMembers.map((m) => getUserSlackId(m.userId))); + const validSlackIds = resolvedSlackIds.filter((id): id is string => !!id); + const mentionPrefix = buildSlackMentionPrefix(SlackMentionType.USER, validSlackIds); + const msg = `:spiral_calendar_pad: ${event.title} for *${drName}* has been scheduled for *${drTime}* ${location} by ${drSubmitter}`; const docLink = event.questionDocumentLink ? `<${event.questionDocumentLink}|Doc Link>` : ''; - const threadMsg = `This event has been Scheduled! \n` + docLink; + const threadMsg = `${mentionPrefix}This event has been Scheduled! \n` + docLink; if (threads && threads.length !== 0) { const msgs = threads.map((thread) => editMessage(thread.channelId, thread.timestamp, msg)); diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 5cfd25848a..1a1ca72110 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -12,7 +12,8 @@ import { ScheduleSlotCreateArgs, EventWithMembers, ScheduleSlot, - EventInstance + EventInstance, + SlackMentionType } from 'shared'; import { getAllShops, @@ -82,6 +83,7 @@ export interface EventCreateArgs { description?: string; initialDateScheduled: Date; scheduleSlots: ScheduleSlotCreateArgs[]; + mention?: SlackMentionType; } export interface EditEventArgs { diff --git a/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx index 05831ee7e4..96a1610e05 100644 --- a/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx @@ -32,7 +32,7 @@ const CreateEventModal: React.FC = ({ const handleSubmit = async (payload: EventPayload) => { try { - const { documentFiles, createScheduleSlotArgs, initialDateScheduled, ...eventData } = payload; + const { documentFiles, createScheduleSlotArgs, initialDateScheduled, mention, ...eventData } = payload; const scheduleSlots: Array<{ startTime: Date; @@ -82,7 +82,8 @@ const CreateEventModal: React.FC = ({ ...eventData, initialDateScheduled: initialDateScheduled ?? new Date(), scheduleSlots, - documentIds: [] + documentIds: [], + mention }; const createdEvent = await createEvent(createArgs); diff --git a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx index 18b2ceaece..b1e8fccad1 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -13,7 +13,10 @@ import { Button, Stack, Checkbox, - FormControlLabel + FormControlLabel, + ToggleButtonGroup, + ToggleButton, + useTheme } from '@mui/material'; import { DatePicker, TimePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; @@ -28,7 +31,8 @@ import { isHead, MAX_FILE_SIZE, getNextSevenDays, - getDay + getDay, + SlackMentionType } from 'shared'; import { useToast } from '../../../hooks/toasts.hooks'; import { useAllMembers, useCurrentUser } from '../../../hooks/users.hooks'; @@ -79,6 +83,7 @@ export interface EventFormValues { recurrenceNumber: number; days: DayOfWeek[]; selectedScheduleSlotId?: string; + mention: SlackMentionType; } export interface EventPayload { @@ -96,6 +101,7 @@ export interface EventPayload { documentFiles: EventDocumentUploadArgs[]; questionDocumentLink?: string; description?: string; + mention: SlackMentionType; // If the event type requires confirmation, only intialDateScheduled will be populated. If not, // scheduleSlots will be populated based on if the event is being editted or created initialDateScheduled?: Date; @@ -144,7 +150,8 @@ const schema = yup.object().shape({ allDay: yup.boolean().required(), recurrenceNumber: yup.number().min(0).required('Recurrence is required'), days: yup.array().of(yup.mixed().required()).default([]), - selectedScheduleSlotId: yup.string().optional() + selectedScheduleSlotId: yup.string().optional(), + mention: yup.mixed().required().default(SlackMentionType.USER) }); export interface BaseEventModalProps { @@ -221,6 +228,7 @@ const EventModal: React.FC = ({ eventId, actionsLeftChildren }) => { + const theme = useTheme(); const toast = useToast(); const user = useCurrentUser(); const [datePickerOpen, setDatePickerOpen] = useState(false); @@ -288,7 +296,8 @@ const EventModal: React.FC = ({ allDay: initialValues?.allDay ?? false, recurrenceNumber: 0, days: [], - selectedScheduleSlotId: initialValues?.selectedScheduleSlotId + selectedScheduleSlotId: initialValues?.selectedScheduleSlotId, + mention: SlackMentionType.USER }; }, [initialValues, defaultDate, defaultStartTime, defaultEndTime]); @@ -506,7 +515,8 @@ const EventModal: React.FC = ({ workPackageIds: data.workPackageIds, documentFiles: data.documentFiles, questionDocumentLink: data.questionDocumentLink, - description: data.description + description: data.description, + mention: data.mention }; // If the event requires confirmation, only populate initialDateScheduled @@ -1191,6 +1201,53 @@ const EventModal: React.FC = ({ )} + + {/* Slack Mention Type Toggle */} + {selectedEventType.sendSlackNotifications && !initialValues && ( + + ( + { + if (val) onChange(val); + }} + size="small" + sx={{ + '& .MuiToggleButton-root': { + borderRadius: 0, + textTransform: 'none', + py: 0.55, + px: 1.1, + borderColor: theme.palette.divider, + color: theme.palette.text.primary, + '&.Mui-selected': { + bgcolor: theme.palette.primary.main, + color: 'black', + '&:hover': { bgcolor: '#ff0000', color: 'white' } + }, + '&:hover': { bgcolor: theme.palette.action.hover } + }, + '& .MuiToggleButton-root:first-of-type': { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8 + }, + '& .MuiToggleButton-root:last-of-type': { + borderTopRightRadius: 8, + borderBottomRightRadius: 8 + } + }} + > + @user + @channel + + )} + /> + + )} )} {/* Required Members Section */} diff --git a/src/shared/src/types/calendar-types.ts b/src/shared/src/types/calendar-types.ts index de8c7d5229..eb49c76548 100644 --- a/src/shared/src/types/calendar-types.ts +++ b/src/shared/src/types/calendar-types.ts @@ -87,6 +87,11 @@ export enum ConflictStatus { NO_CONFLICT = 'NO_CONFLICT' } +export enum SlackMentionType { + USER = 'USER', + CHANNEL = 'CHANNEL' +} + export interface Calendar { calendarId: string; name: string;