Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/backend/src/controllers/calendar.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,8 @@ export default class CalendarController {
questionDocumentLink,
location,
zoomLink,
description
description,
mention
} = req.body;

const parsedScheduleSlots = scheduleSlots.map((slot: any) => ({
Expand Down Expand Up @@ -307,7 +308,8 @@ export default class CalendarController {
questionDocumentLink,
location,
zoomLink,
description
description,
mention
);
res.status(200).json(event);
} catch (error: unknown) {
Expand Down
1 change: 1 addition & 0 deletions src/backend/src/routes/calendar.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
9 changes: 6 additions & 3 deletions src/backend/src/services/calendar.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -270,7 +271,8 @@ export default class CalendarService {
questionDocumentLink?: string,
location?: string,
zoomLink?: string,
description?: string
description?: string,
mention?: SlackMentionType
): Promise<Event> {
// Validate eventTypeId
const foundEventType = await prisma.event_Type.findUnique({
Expand Down Expand Up @@ -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 }
);
}

Expand Down
34 changes: 27 additions & 7 deletions src/backend/src/utils/slack.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -383,35 +384,49 @@ export const sendAndGetSlackCRNotifications = async (
return notifications;
};

export const buildSlackMentionPrefix = (mention: SlackMentionType, memberSlackIds: string[]): string => {
if (mention === SlackMentionType.CHANNEL) return '<!channel> ';
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<void>[] = teams.map(async (team) => {
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/hooks/calendar.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
ScheduleSlotCreateArgs,
EventWithMembers,
ScheduleSlot,
EventInstance
EventInstance,
SlackMentionType
} from 'shared';
import {
getAllShops,
Expand Down Expand Up @@ -82,6 +83,7 @@ export interface EventCreateArgs {
description?: string;
initialDateScheduled: Date;
scheduleSlots: ScheduleSlotCreateArgs[];
mention?: SlackMentionType;
}

export interface EditEventArgs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const CreateEventModal: React.FC<CreateEventModalProps> = ({

const handleSubmit = async (payload: EventPayload) => {
try {
const { documentFiles, createScheduleSlotArgs, initialDateScheduled, ...eventData } = payload;
const { documentFiles, createScheduleSlotArgs, initialDateScheduled, mention, ...eventData } = payload;

const scheduleSlots: Array<{
startTime: Date;
Expand Down Expand Up @@ -82,7 +82,8 @@ const CreateEventModal: React.FC<CreateEventModalProps> = ({
...eventData,
initialDateScheduled: initialDateScheduled ?? new Date(),
scheduleSlots,
documentIds: []
documentIds: [],
mention
};

const createdEvent = await createEvent(createArgs);
Expand Down
67 changes: 62 additions & 5 deletions src/frontend/src/pages/CalendarPage/Components/EventModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -79,6 +83,7 @@ export interface EventFormValues {
recurrenceNumber: number;
days: DayOfWeek[];
selectedScheduleSlotId?: string;
mention: SlackMentionType;
}

export interface EventPayload {
Expand All @@ -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;
Expand Down Expand Up @@ -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<DayOfWeek>().required()).default([]),
selectedScheduleSlotId: yup.string().optional()
selectedScheduleSlotId: yup.string().optional(),
mention: yup.mixed<SlackMentionType>().required().default(SlackMentionType.USER)
});

export interface BaseEventModalProps {
Expand Down Expand Up @@ -221,6 +228,7 @@ const EventModal: React.FC<BaseEventModalProps> = ({
eventId,
actionsLeftChildren
}) => {
const theme = useTheme();
const toast = useToast();
const user = useCurrentUser();
const [datePickerOpen, setDatePickerOpen] = useState(false);
Expand Down Expand Up @@ -288,7 +296,8 @@ const EventModal: React.FC<BaseEventModalProps> = ({
allDay: initialValues?.allDay ?? false,
recurrenceNumber: 0,
days: [],
selectedScheduleSlotId: initialValues?.selectedScheduleSlotId
selectedScheduleSlotId: initialValues?.selectedScheduleSlotId,
mention: SlackMentionType.USER
};
}, [initialValues, defaultDate, defaultStartTime, defaultEndTime]);

Expand Down Expand Up @@ -506,7 +515,8 @@ const EventModal: React.FC<BaseEventModalProps> = ({
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
Expand Down Expand Up @@ -1191,6 +1201,53 @@ const EventModal: React.FC<BaseEventModalProps> = ({
)}
</Box>
</Tooltip>

{/* Slack Mention Type Toggle */}
{selectedEventType.sendSlackNotifications && !initialValues && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, marginLeft: 'auto' }}>
<Controller
name="mention"
control={control}
render={({ field: { onChange, value } }) => (
<ToggleButtonGroup
value={value}
exclusive
onChange={(_, val) => {
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
}
}}
>
<ToggleButton value={SlackMentionType.USER}>@user</ToggleButton>
<ToggleButton value={SlackMentionType.CHANNEL}>@channel</ToggleButton>
</ToggleButtonGroup>
)}
/>
</Box>
)}
</Box>
)}
{/* Required Members Section */}
Expand Down
5 changes: 5 additions & 0 deletions src/shared/src/types/calendar-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export enum ConflictStatus {
NO_CONFLICT = 'NO_CONFLICT'
}

export enum SlackMentionType {
USER = 'USER',
CHANNEL = 'CHANNEL'
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small, but these should be all caps


export interface Calendar {
calendarId: string;
name: string;
Expand Down
Loading