diff --git a/.env.example b/.env.example index c53692cf..5fee66bc 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,6 @@ SLACK_SIGNING_SECRET= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_TEAM_ID= # Should be set only if the slack integration is used across multiple workspaces +SLACK_CHECK_WITH_TECH_CHANNEL= SLACK_USER_FEEDBACK_CHANNEL= SENTRY_PROJECT_ID= diff --git a/app/(authenticated)/calendar/[eventID]/CheckWithTech.tsx b/app/(authenticated)/calendar/[eventID]/CheckWithTech.tsx index 2483dd30..402b03a8 100644 --- a/app/(authenticated)/calendar/[eventID]/CheckWithTech.tsx +++ b/app/(authenticated)/calendar/[eventID]/CheckWithTech.tsx @@ -1,10 +1,33 @@ "use client"; -import { Alert, Button, ButtonGroup, Modal, Textarea } from "@mantine/core"; +import { + Alert, + Button, + ButtonGroup, + Modal, + ModalBody, + ModalContent, + ModalHeader, + ModalTitle, + Space, + Stack, + Textarea, +} from "@mantine/core"; import { Suspense, cache, use, useState, useTransition } from "react"; -import { TbBrandSlack, TbTool } from "react-icons/tb"; -import { doCheckWithTech, equipmentListTemplates } from "./actions"; +import { TbTool } from "react-icons/tb"; +import { + actionCheckWithTech, + doCheckWithTech, + equipmentListTemplates, +} from "./actions"; import { notifications } from "@mantine/notifications"; +import { CheckWithTechType } from "@/features/calendar"; +import { getUserName } from "@/components/UserHelpers"; +import { useModals } from "@mantine/modals"; +import Form from "@/components/Form"; +import { CheckWithTechActionSchema } from "./schema"; +import { HiddenField, TextAreaField } from "@/components/FormFields"; +import SlackIcon from "@/components/icons/SlackIcon"; const _getEquipmentListTemplates = cache(equipmentListTemplates); @@ -118,7 +141,8 @@ function PostMessage(props: { } }) } - leftSection={} + variant="light" + leftSection={} > Send @@ -189,3 +213,100 @@ export function CheckWithTechPromptContents(props: { eventID: number }) { ); } + +export function CheckWithTechAdminBanner({ cwt }: { cwt: CheckWithTechType }) { + const [modalOpen, setModalOpen] = useState< + "approve" | "note" | "decline" | null + >(null); + + return ( + <> + } + > + + #CheckWithTech request from {getUserName(cwt.submitted_by_user)} + +

{cwt.request}

+ + {cwt.unsure && ( + + Note: {cwt.submitted_by_user.first_name} was not sure of what they + need - please get in touch and amend as needed + + )} + {cwt.notes.length > 0 &&

Notes: {cwt.notes}

} + {cwt.status !== "Requested" && ( + + {cwt.status} + {cwt.confirmed_by_user && + " by " + getUserName(cwt.confirmed_by_user)} + + )} +
+ {cwt.status === "Requested" && ( + + + + + + )} +
+ + setModalOpen(null)}> + + + {modalOpen === "approve" + ? "Approve" + : modalOpen === "note" + ? "Leave Note" + : "Decline"} + + + + {modalOpen !== null && ( +
setModalOpen(null)} + initialValues={{ + action: modalOpen, + cwtID: cwt.cwt_id, + eventID: cwt.event_id, + request: cwt.request, + note: cwt.notes, + }} + submitLabel={ + modalOpen === "approve" + ? "Approve" + : modalOpen === "note" + ? "Leave Note" + : "Decline" + } + submitColor={ + modalOpen === "approve" + ? "green" + : modalOpen === "note" + ? "blue" + : "red" + } + > + + {modalOpen === "approve" && ( + + )} + + + )} +
+
+ + ); +} diff --git a/app/(authenticated)/calendar/[eventID]/actions.ts b/app/(authenticated)/calendar/[eventID]/actions.ts index 66eaed6e..36f75db8 100644 --- a/app/(authenticated)/calendar/[eventID]/actions.ts +++ b/app/(authenticated)/calendar/[eventID]/actions.ts @@ -11,7 +11,10 @@ import * as Calendar from "@/features/calendar"; import { EventType, hasRSVP } from "@/features/calendar/types"; import { canManage } from "@/features/calendar/permissions"; import { zodErrorResponse } from "@/components/FormServerHelpers"; -import { EditEventSchema } from "@/app/(authenticated)/calendar/[eventID]/schema"; +import { + CheckWithTechActionSchema, + EditEventSchema, +} from "@/app/(authenticated)/calendar/[eventID]/schema"; import { FormResponse } from "@/components/Form"; import { updateEventAttendeeStatus } from "@/features/calendar/events"; import invariant from "@/lib/invariant"; @@ -309,11 +312,13 @@ export const doCheckWithTech = wrapServerAction( }; } - if (isConfident) { - await Calendar.postCheckWithTech(eventID, memo); - } else { - await Calendar.postTechHelpRequest(eventID, memo); - } + await Calendar.postCheckWithTech( + eventID, + memo, + isConfident ? "check" : "help", + ); + + revalidatePath(`/calendar/${event.event_id}`); return { ok: true }; }, @@ -325,3 +330,34 @@ export const equipmentListTemplates = wrapServerAction( return await Calendar.getEquipmentListTemplates(); }, ); + +export const actionCheckWithTech = wrapServerAction( + "actionCheckWithTech", + async function actionCheckWithTech(dataRaw: unknown): Promise { + const data = CheckWithTechActionSchema.safeParse(dataRaw); + if (!data.success) { + return zodErrorResponse(data.error); + } + const { cwtID, action, note, request, eventID } = data.data; + switch (action) { + case "approve": + if (!request) { + return { ok: false, errors: { request: "No request provided" } }; + } + await Calendar.approveCheckWithTech(cwtID, request, note); + break; + case "note": + if (!note) { + return { ok: false, errors: { note: "No note provided" } }; + } + await Calendar.addNoteToCheckWithTech(cwtID, note); + break; + case "decline": + await Calendar.declineCheckWithTech(cwtID, note); + break; + } + + revalidatePath(`/calendar/${eventID}`); + return { ok: true }; + }, +); diff --git a/app/(authenticated)/calendar/[eventID]/page.tsx b/app/(authenticated)/calendar/[eventID]/page.tsx index 110c9202..1ee0f87f 100644 --- a/app/(authenticated)/calendar/[eventID]/page.tsx +++ b/app/(authenticated)/calendar/[eventID]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation"; import invariant from "@/lib/invariant"; import { getUserName } from "@/components/UserHelpers"; -import { mustGetCurrentUser, UserType } from "@/lib/auth/server"; +import { hasPermission, mustGetCurrentUser, UserType } from "@/lib/auth/server"; import { CurrentUserAttendeeRow } from "@/app/(authenticated)/calendar/[eventID]/AttendeeStatus"; import { AttendStatusLabels } from "@/features/calendar/statuses"; import { SignupSheetsView } from "@/app/(authenticated)/calendar/[eventID]/SignupSheet"; @@ -13,6 +13,7 @@ import { canManage, canManageAnySignupSheet, getAllCrewPositions, + getLatestRequest, } from "@/features/calendar"; import { CrewPositionsProvider, @@ -20,15 +21,18 @@ import { } from "@/components/FormFieldPreloadedData"; import { getAllUsers } from "@/features/people"; import { EventActionsUI } from "./EventActionsUI"; -import { Alert, Space, Text } from "@mantine/core"; -import { TbInfoCircle, TbAlertTriangle } from "react-icons/tb"; +import { Alert, Button, ButtonGroup, Space, Text } from "@mantine/core"; +import { TbInfoCircle, TbAlertTriangle, TbTool } from "react-icons/tb"; import slackApiConnection, { isSlackEnabled, } from "@/lib/slack/slackApiConnection"; import { Suspense } from "react"; import SlackChannelName from "@/components/slack/SlackChannelName"; import SlackLoginButton from "@/components/slack/SlackLoginButton"; -import { CheckWithTechPromptContents } from "./CheckWithTech"; +import { + CheckWithTechAdminBanner, + CheckWithTechPromptContents, +} from "./CheckWithTech"; import { C } from "@fullcalendar/core/internal-common"; import dayjs from "dayjs"; import { PageInfo } from "@/components/PageInfo"; @@ -99,10 +103,6 @@ async function CheckWithTechPrompt({ if (!canManageAnySignupSheet(event, me)) { return null; } - if (event.adam_rms_project_id || event.check_with_tech_status) { - // assume already checked - return null; - } if (dayjs(event.start_date).isBefore(new Date())) { // no point checking something in the past return null; @@ -111,13 +111,56 @@ async function CheckWithTechPrompt({ // signup sheets take priority return null; } - const slack = await slackApiConnection(); - if (!slack) { + if (!isSlackEnabled) { + return null; + } + const cwt = await getLatestRequest(event.event_id); + + if (cwt && (await hasPermission("CheckWithTech.Admin"))) { + return ; + } + + if (event.adam_rms_project_id !== null) { + // Assume already checked + return null; + } + + if (!(await hasPermission("CheckWithTech.Submit"))) { return null; } + + let contents; + if (!cwt) { + contents = ; + } else { + switch (cwt.status) { + case "Rejected": + // Don't show rejected CWTs, just prompt to create a new one + contents = ; + break; + case "Requested": + contents = ( + } + > + Your #CheckWithTech has been submitted to the tech team. Keep an eye + on Slack in case they need any further details! + + ); + break; + case "Confirmed": + contents = null; // Don't show anything if it's already confirmed, reduce banner fatigue + break; + default: + invariant(false, `unexpected CWT status: ${cwt.status}`); + } + } return ( <> - + {contents} ); diff --git a/app/(authenticated)/calendar/[eventID]/schema.ts b/app/(authenticated)/calendar/[eventID]/schema.ts index 3022379d..5c6c9001 100644 --- a/app/(authenticated)/calendar/[eventID]/schema.ts +++ b/app/(authenticated)/calendar/[eventID]/schema.ts @@ -47,3 +47,11 @@ export const SignupSheetSchema = z.object({ unlock_date: z.coerce.date().nullable(), crews: z.array(CrewSchema), }); + +export const CheckWithTechActionSchema = z.object({ + cwtID: z.coerce.number(), + eventID: z.coerce.number(), + action: z.enum(["approve", "note", "decline"]), + note: z.string().optional(), + request: z.string().optional(), +}); diff --git a/components/Form.tsx b/components/Form.tsx index 6b5458bd..f38e3b89 100644 --- a/components/Form.tsx +++ b/components/Form.tsx @@ -11,7 +11,7 @@ import { useCallback, useState, useTransition } from "react"; import classNames from "classnames"; import { FieldPath } from "react-hook-form/dist/types/path"; import { DebugOnly } from "@/components/DebugMode"; -import { Button } from "@mantine/core"; +import { Button, DefaultMantineColor } from "@mantine/core"; export interface FormErrorResponse { ok: false; @@ -44,6 +44,7 @@ export default function Form< children: React.ReactNode; className?: string; submitLabel?: string; + submitColor?: DefaultMantineColor; onSuccess?: (res: SuccessfulResponse) => void; }) { const form = useForm>({ @@ -106,6 +107,7 @@ export default function Form< type="submit" disabled={!form.formState.isValid} loading={isSubmitting} + color={props.submitColor} > {props.submitLabel ?? "Create"} diff --git a/docs/setup_slack.md b/docs/setup_slack.md index f1fba401..eabe0879 100644 --- a/docs/setup_slack.md +++ b/docs/setup_slack.md @@ -51,6 +51,15 @@ Sroll down slightly to `User Token Scopes` and add `team.read`. You can now scroll up to the top of the page and click `Install to Workspace`. Click `Allow` when prompted. This should take you back to the **OAuth & Permissions** page. Towards the top of the page you should see `Bot User OAuth Token`. Copy the value of this over to `SLACK_BOT_TOKEN`. +## Setting up channels + +This app uses a number of slack channels for integration with check-with-tech responses, feedback, and others. These three channels are: + +- `SLACK_CHECK_WITH_TECH_CHANNEL` - Used for check-with-tech requests +- `SLACK_USER_FEEDBACK_CHANNEL` - Used for user feedback via the feedback form at the bottom of each page + +Once you have channels you would like to use for this purpose, get the channel ID by copying the link of the channel and taking the last bit of the link that looks something like `C07J1G4L0BA` and set the variables accordingly. + ## Enable Integration Once these five variables are set, enable the integration by setting `SLACK_ENABLED` to `true`. If you start up your instance you should now be able to link your Slack account from your user profile and start assigning channels to events. If you have any questions ask Mia because she's probably made it a bit complicated. diff --git a/features/calendar/adamRMS.ts b/features/calendar/adamRMS.ts index 3eda9b5b..d9a81a6e 100644 --- a/features/calendar/adamRMS.ts +++ b/features/calendar/adamRMS.ts @@ -44,6 +44,7 @@ export async function addProjectToAdamRMS( projectId, `This project is linked to the Calendar event "${event.name}" (${env.PUBLIC_URL}/calendar/${event.event_id}).`, ); + return projectId; } export async function getAdamRMSLinkCandidates() { diff --git a/features/calendar/check_with_tech.ts b/features/calendar/check_with_tech.ts index 174d817b..317b3342 100644 --- a/features/calendar/check_with_tech.ts +++ b/features/calendar/check_with_tech.ts @@ -1,4 +1,4 @@ -import { getCurrentUser } from "@/lib/auth/server"; +import { userHasPermission } from "@/lib/auth/core"; import slackApiConnection from "@/lib/slack/slackApiConnection"; import { getEvent } from "./events"; import dayjs from "dayjs"; @@ -6,15 +6,28 @@ import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { prisma } from "@/lib/db"; import { env } from "@/lib/env"; +import { + getCurrentUser, + hasPermission, + mustGetCurrentUser, + requirePermission, +} from "@/lib/auth/server"; +import { ExposedUserModel } from "../people/users"; +import * as AdamRMS from "@/lib/adamrms"; +import { getUserName } from "@/components/UserHelpers"; +import { addProjectToAdamRMS } from "./adamRMS"; +import { _sendCWTFollowUpAndUpdateMessage } from "./check_with_tech_actions"; +import invariant from "@/lib/invariant"; dayjs.extend(utc); dayjs.extend(timezone); -export async function postCheckWithTech(eventID: number, memo: string) { +export async function postCheckWithTech( + eventID: number, + memo: string, + type: "check" | "help", +) { const slack = await slackApiConnection(); - if (!slack) { - throw new Error("No Slack app"); - } const event = await getEvent(eventID); if (!event) { throw new Error("Event not found"); @@ -26,7 +39,9 @@ export async function postCheckWithTech(eventID: number, memo: string) { : `${me.first_name} ${me.last_name}`; const lines = [ - `*#check-with-tech request from ${user}*`, + type === "help" + ? `*${user} needs help with their production*` + : `*#check-with-tech request from ${user}*`, event.name, dayjs(event.start_date) .tz("Europe/London") @@ -42,61 +57,216 @@ export async function postCheckWithTech(eventID: number, memo: string) { memo, ]; - await slack.client.chat.postMessage({ + const cwt = await prisma.checkWithTech.create({ + data: { + event_id: eventID, + submitted_by: me.user_id, + request: memo, + unsure: type === "help", + }, + }); + + const res = await slack.client.chat.postMessage({ channel: env.SLACK_CHECK_WITH_TECH_CHANNEL ?? "#check-with-tech", - text: lines.join("\n"), - mrkdwn: true, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: lines.join("\n"), + }, + }, + { + type: "context", + elements: [ + { + type: "plain_text", + text: "For tech team use only:", + }, + ], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Approve", + }, + value: cwt.cwt_id.toString(), + action_id: "checkWithTech#approve", + style: "primary", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Add Note", + }, + value: cwt.cwt_id.toString(), + action_id: "checkWithTech#note", + }, + { + type: "button", + text: { + type: "plain_text", + text: "Decline", + }, + value: cwt.cwt_id.toString(), + action_id: "checkWithTech#decline", + }, + ], + }, + ], }); + invariant(res.ok, "Failed to send message"); + await prisma.checkWithTech.update({ + where: { + cwt_id: cwt.cwt_id, + }, + data: { + slack_message_ts: res.ts, + }, + }); +} - await prisma.event.update({ - where: { event_id: eventID }, - data: { check_with_tech_status: "Requested" }, +export async function getEquipmentListTemplates() { + return await prisma.equipmentListTemplate.findMany({ + where: { + archived: false, + }, }); } -export async function postTechHelpRequest(eventID: number, memo: string) { - const slack = await slackApiConnection(); - if (!slack) { - throw new Error("No Slack app"); - } - const event = await getEvent(eventID); - if (!event) { - throw new Error("Event not found"); +export async function getLatestRequest(eventID: number) { + const r = await prisma.checkWithTech.findFirst({ + where: { + event_id: eventID, + }, + orderBy: { + submitted_at: "desc", + }, + include: { + submitted_by_user: true, + confirmed_by_user: true, + }, + }); + if (!r) { + return null; } - const me = await getCurrentUser(); - const slackUser = me.identities.find((x) => x.provider === "slack"); - const user = slackUser - ? `<@${slackUser.provider_key}>` - : `${me.first_name} ${me.last_name}`; + return { + ...r, + submitted_by_user: ExposedUserModel.parse(r.submitted_by_user), + confirmed_by_user: r.confirmed_by_user + ? ExposedUserModel.parse(r.confirmed_by_user) + : null, + }; +} - const lines = [ - `*${user} needs help with their production*`, - event.name, - dayjs(event.start_date) - .tz("Europe/London") - .format("dddd, MMMM D, YYYY h:mma") + - " - " + - (dayjs(event.end_date).isSame(event.start_date, "day") - ? dayjs(event.end_date).tz("Europe/London").format("h:mma") - : dayjs(event.end_date) - .tz("Europe/London") - .format("dddd, MMMM D, YYYY h:mma")), - `${env.PUBLIC_URL}/calendar/${eventID}`, - event.location, - memo, - ]; +export type CheckWithTechType = NonNullable< + Awaited> +>; - await slack.client.chat.postMessage({ - channel: env.SLACK_TECH_HELP_CHANNEL ?? "#check-with-tech", - text: lines.join("\n"), - mrkdwn: true, +export async function approveCheckWithTech( + cwtID: number, + newRequest: string, + notes?: string, +) { + await requirePermission("CheckWithTech.Admin"); + const cwt = await prisma.checkWithTech.findFirst({ + where: { + cwt_id: cwtID, + }, + include: { + event: true, + submitted_by_user: { + include: { + identities: { + where: { + provider: "slack", + }, + }, + }, + }, + }, }); + if (!cwt) { + throw new Error("Request not found"); + } + if (cwt.confirmed_by) { + throw new Error("Request already confirmed"); + } + const me = await mustGetCurrentUser(); + await prisma.checkWithTech.update({ + where: { + cwt_id: cwtID, + }, + data: { + status: "Confirmed", + confirmed_by: me.user_id, + confirmed_at: new Date(), + request: newRequest, + notes: notes, + }, + }); + await _sendCWTFollowUpAndUpdateMessage( + cwt, + me, + "Confirmed", + notes, + newRequest, + ); } -export async function getEquipmentListTemplates() { - return await prisma.equipmentListTemplate.findMany({ +export async function declineCheckWithTech(cwtID: number, notes?: string) { + await requirePermission("CheckWithTech.Admin"); + const cwt = await prisma.checkWithTech.findFirst({ where: { - archived: false, + cwt_id: cwtID, + }, + include: { + event: true, + submitted_by_user: { + include: { + identities: { + where: { + provider: "slack", + }, + }, + }, + }, + }, + }); + if (!cwt) { + throw new Error("Request not found"); + } + if (cwt.confirmed_by) { + throw new Error("Request already confirmed"); + } + const me = await mustGetCurrentUser(); + await prisma.checkWithTech.update({ + where: { + cwt_id: cwtID, + }, + data: { + status: "Rejected", + confirmed_at: new Date(), + confirmed_by: me.user_id, + notes: notes, + }, + }); + await _sendCWTFollowUpAndUpdateMessage(cwt, me, "Rejected", notes); +} + +export async function addNoteToCheckWithTech(cwtID: number, notes: string) { + await requirePermission("CheckWithTech.Admin"); + await prisma.checkWithTech.update({ + where: { + cwt_id: cwtID, + }, + data: { + notes: notes, }, }); } diff --git a/features/calendar/check_with_tech_actions.ts b/features/calendar/check_with_tech_actions.ts new file mode 100644 index 00000000..8d61ea69 --- /dev/null +++ b/features/calendar/check_with_tech_actions.ts @@ -0,0 +1,477 @@ +import { getUserName } from "@/components/UserHelpers"; +import { userHasPermission } from "@/lib/auth/core"; +import { prisma } from "@/lib/db"; +import invariant from "@/lib/invariant"; +import slackApiConnection from "@/lib/slack/slackApiConnection"; +import { + CheckWithTech, + CheckWithTechStatus, + Event, + Identity, + User, +} from "@prisma/client"; +import * as AdamRMS from "@/lib/adamrms"; +import { + SlackActionMiddlewareArgs, + BlockAction, + ButtonAction, + SlackViewMiddlewareArgs, + ViewSubmitAction, + ContextBlock, + Block, + SectionBlock, +} from "@slack/bolt"; +import dayjs from "dayjs"; +import { env } from "@/lib/env"; +import { z } from "zod"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import { addProjectToAdamRMS } from "./adamRMS"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const ModelPrivateMetadataSchema = z.object({ + cwtID: z.number(), +}); +type ModalPrivateMetadata = z.infer; + +export async function handleSlackAction(data: SlackActionMiddlewareArgs) { + if (!assertIsButtonAction(data)) { + return; + } + const { ack, action, respond, payload, body, say } = data; + await ack(); + + const actorId = body.user.id; + const actor = await prisma.user.findFirst({ + where: { + identities: { + some: { + provider: "slack", + provider_key: actorId, + }, + }, + }, + }); + + const api = await slackApiConnection(); + + if (!actor) { + await api.client.chat.postEphemeral({ + channel: body.channel!.id, + user: body.user.id, + text: `Please <${env.PUBLIC_URL}/user/me|link your Internal Site profile to Slack> to use this feature.`, + }); + return; + } + if (!userHasPermission(actor.user_id, "CheckWithTech.Admin")) { + await api.client.chat.postEphemeral({ + channel: body.channel!.id, + user: body.user.id, + text: "You do not have permission to use this feature.", + }); + return; + } + + const type = action.action_id.replace(/^checkWithTech#/, ""); + invariant(action.value, "Value not found in action"); + const cwtID = parseInt(action.value); + const cwt = await prisma.checkWithTech.findUnique({ + where: { + cwt_id: cwtID, + }, + include: { + submitted_by_user: true, + }, + }); + if (!cwt) { + await api.client.chat.postEphemeral({ + channel: body.channel!.id, + user: body.user.id, + text: "Something went wrong internally (CWT object not found). Please report this to the Computing Team.", + }); + return; + } + invariant(body.message, "Message not found in action body"); + invariant(body.channel, "Channel not found in action body"); + const metadata: ModalPrivateMetadata = { + cwtID, + }; + switch (type) { + case "approve": + await api.client.views.open({ + trigger_id: body.trigger_id, + view: { + type: "modal", + title: { + text: "Approve Request", + type: "plain_text", + }, + blocks: [ + { + type: "input", + block_id: "request", + element: { + type: "plain_text_input", + action_id: "request", + multiline: true, + initial_value: cwt.request, + }, + label: { + type: "plain_text", + text: "Request", + }, + hint: { + type: "plain_text", + text: "This is the original request. Feel free to make any changes - these will get copied to AdamRMS.", + }, + }, + { + type: "input", + block_id: "notes", + element: { + type: "plain_text_input", + action_id: "notes", + multiline: true, + initial_value: cwt.notes, + }, + label: { + type: "plain_text", + text: "Notes", + }, + hint: { + type: "plain_text", + text: "Add any relevant notes here. These will be visible to the requestor and other members of the tech team.", + }, + optional: true, + }, + (cwt.unsure && { + type: "context", + elements: [ + { + type: "plain_text", + text: + cwt.submitted_by_user.first_name + + " indicated they were unsure of what they need - please get in touch and amend as needed.", + }, + ], + }) as ContextBlock, + { + type: "context", + elements: [ + { + type: "plain_text", + text: "This will send a message to the requestor, containing the above note.", + }, + ], + }, + ].filter(Boolean), + submit: { + type: "plain_text", + text: "Approve", + }, + callback_id: "checkWithTech#doApprove", + private_metadata: JSON.stringify(metadata), + }, + }); + break; + case "note": + case "decline": + await api.client.views.open({ + trigger_id: body.trigger_id, + view: { + type: "modal", + title: { + text: type === "note" ? "Add Note" : "Decline Request", + type: "plain_text", + }, + blocks: [ + { + type: "input", + block_id: "notes", + element: { + type: "plain_text_input", + action_id: "notes", + multiline: true, + initial_value: cwt.notes, + }, + label: { + type: "plain_text", + text: "Notes", + }, + hint: { + type: "plain_text", + text: "Add any relevant notes here. These will be visible to the requestor and other members of the tech team.", + }, + optional: true, + }, + { + type: "context", + elements: [ + { + type: "plain_text", + text: + type === "note" + ? "This will not send a message to the requestor - you will need to get in touch with them directly." + : "This will send a message to the requestor, containing the above note.", + }, + ], + }, + ].filter(Boolean), + submit: { + type: "plain_text", + text: type === "note" ? "Add Note" : "Decline", + }, + // prettier-ignore + callback_id: `checkWithTech#do${type === "note" ? "Note" : "Decline"}`, + private_metadata: JSON.stringify(metadata), + }, + }); + break; + default: + await api.client.chat.postEphemeral({ + channel: body.channel!.id, + user: body.user.id, + text: `Something went wrong internally (unknown action type ${type}). Please report this to the Computing Team.`, + }); + } +} + +function assertIsButtonAction( + args: SlackActionMiddlewareArgs, +): args is SlackActionMiddlewareArgs> { + return args.payload.type === "button"; +} + +export async function handleSlackViewEvent(data: SlackViewMiddlewareArgs) { + if (!assertIsViewSubmitAction(data)) { + return; + } + const { ack, body, view } = data; + + const actorId = body.user.id; + const actor = await prisma.user.findFirst({ + where: { + identities: { + some: { + provider: "slack", + provider_key: actorId, + }, + }, + }, + }); + if (!actor) { + await ack({ + response_action: "errors", + errors: { + request: + "Please link your Internal Site profile to Slack to use this feature.", + }, + }); + return; + } + if (!userHasPermission(actor.user_id, "CheckWithTech.Admin")) { + await ack({ + response_action: "errors", + errors: { + request: "You do not have permission to use this feature.", + }, + }); + return; + } + + const meta = ModelPrivateMetadataSchema.parse( + JSON.parse(body.view.private_metadata), + ); + + const cwt = await prisma.checkWithTech.findUnique({ + where: { + cwt_id: meta.cwtID, + }, + include: { + event: true, + submitted_by_user: { + include: { + identities: { + where: { + provider: "slack", + }, + }, + }, + }, + }, + }); + if (!cwt) { + await ack({ + response_action: "errors", + errors: { + request: + "Something went wrong internally (CWT object not found). Please report this to the Computing Team.", + }, + }); + return; + } + + const values = view.state.values; + const notes = values.notes?.notes.value; + let newStatus: CheckWithTechStatus | undefined; + const reqType = body.view.callback_id.replace(/^checkWithTech#do/, ""); + let request; + switch (reqType) { + case "Approve": + newStatus = "Confirmed"; + request = values.request?.request.value; + if (!request) { + await ack({ + response_action: "errors", + errors: { + request: "Request cannot be empty.", + }, + }); + return; + } + break; + case "Decline": + newStatus = "Rejected"; + break; + } + await prisma.checkWithTech.update({ + where: { + cwt_id: meta.cwtID, + }, + data: { + request, + notes: notes ?? undefined, + status: newStatus, + confirmed_by: newStatus ? actor.user_id : undefined, + confirmed_at: newStatus ? new Date() : undefined, + }, + }); + await ack(); + + if (!newStatus) { + return; + } + await _sendCWTFollowUpAndUpdateMessage( + cwt, + actor, + newStatus, + notes ?? "", + request, + ); +} + +export interface FullCheckWithTech extends CheckWithTech { + event: Event; + submitted_by_user: User & { + identities: Identity[]; + }; +} + +export async function _sendCWTFollowUpAndUpdateMessage( + cwt: FullCheckWithTech, + actor: User, + newStatus: CheckWithTechStatus, + newNotes: string = "", + newRequest?: string, +) { + if ( + newStatus === "Confirmed" && + env.ADAMRMS_BASE !== "" && + (await userHasPermission(actor.user_id, "CalendarIntegration.Admin")) + ) { + let armsID = cwt.event.adam_rms_project_id; + if (!armsID) { + armsID = await addProjectToAdamRMS(cwt.event_id, actor.user_id); + } + await AdamRMS.newQuickProjectComment( + armsID!, + `Check With Tech confirmed by ${getUserName(actor)}
${newRequest}` + + (newNotes.length > 0 ? `
Notes: ${newNotes}` : ""), + ); + } + + invariant(cwt.slack_message_ts, "Slack message TS not found"); + invariant( + env.SLACK_CHECK_WITH_TECH_CHANNEL, + "SLACK_CHECK_WITH_TECH_CHANNEL not set", + ); + const requestor = cwt.submitted_by_user.identities.find( + (x) => x.provider === "slack", + ); + if (!requestor) { + return; + } + const api = await slackApiConnection(); + const responseParts = [ + `Your #check-with-tech request for ${ + cwt.event.name + } has been ${newStatus.toLowerCase()} by ${getUserName(actor)}.`, + newNotes ? `Notes: ${newNotes}` : "", + `View your event <${env.PUBLIC_URL}/calendar/${cwt.event_id}|here>.`, + ].filter(Boolean); + await api.client.chat.postMessage({ + channel: requestor.provider_key, + text: responseParts.join("\n"), + mrkdwn: true, + }); + + let newContext; + switch (newStatus) { + case "Confirmed": + newContext = `:white_check_mark: Approved by ${getUserName(actor)}`; + break; + case "Rejected": + newContext = `:x: Declined by ${getUserName(actor)}`; + break; + } + + const lines = [ + `*#check-with-tech request from ${getUserName(cwt.submitted_by_user)}*`, + cwt.event.name, + dayjs(cwt.event.start_date) + .tz("Europe/London") + .format("dddd, MMMM D, YYYY h:mma") + + " - " + + (dayjs(cwt.event.end_date).isSame(cwt.event.start_date, "day") + ? dayjs(cwt.event.end_date).tz("Europe/London").format("h:mma") + : dayjs(cwt.event.end_date) + .tz("Europe/London") + .format("dddd, MMMM D, YYYY h:mma")), + `${env.PUBLIC_URL}/calendar/${cwt.event_id}`, + cwt.event.location, + newRequest ?? cwt.request, + ]; + + await api.client.chat.update({ + channel: env.SLACK_CHECK_WITH_TECH_CHANNEL, + ts: cwt.slack_message_ts, + text: [...lines, newContext].join("\n"), + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: lines.join("\n"), + }, + }, + { + type: "context", + elements: [ + { + type: "plain_text", + text: newContext!, + emoji: true, + }, + ], + }, + ], + }); +} + +function assertIsViewSubmitAction( + args: SlackViewMiddlewareArgs, +): args is SlackViewMiddlewareArgs { + return args.body.type === "view_submission"; +} diff --git a/features/calendar/events.ts b/features/calendar/events.ts index d8a24bed..b23753c1 100644 --- a/features/calendar/events.ts +++ b/features/calendar/events.ts @@ -47,7 +47,6 @@ export interface EventObjectType { host: number; host_user: ExposedUser; slack_channel_id: string | null; - check_with_tech_status: CheckWithTechStatus | null; } export interface EventCreateUpdateFields { diff --git a/features/userFeedback/index.ts b/features/userFeedback/index.ts index 63f41e7d..a96ab117 100644 --- a/features/userFeedback/index.ts +++ b/features/userFeedback/index.ts @@ -109,7 +109,7 @@ export async function submit( url: `https://ystv.sentry.io/issues/?project=${ env.SENTRY_PROJECT_ID }&query=user.email%3A${encodeURIComponent(me.email)}&statsPeriod=7d`, - action_id: "user_feedback__search_sentry", + action_id: "userFeedback#searchSentry", }, ], }); diff --git a/lib/auth/core.ts b/lib/auth/core.ts new file mode 100644 index 00000000..72aad454 --- /dev/null +++ b/lib/auth/core.ts @@ -0,0 +1,62 @@ +import { Identity, User } from "@prisma/client"; +import { Permission } from "./permissions"; +import { prisma } from "../db"; +import { cache } from "react"; + +export interface UserWithIdentities extends User { + identities: Identity[]; +} + +export type UserType = UserWithIdentities & { + permissions: Permission[]; +}; + +export async function userHasPermission( + user: UserType | number, + ...perms: Permission[] +) { + let userPerms; + if (typeof user === "number") { + userPerms = await resolvePermissionsForUser(user); + } else { + userPerms = await resolvePermissionsForUser(user.user_id); + } + for (const perm of perms) { + if (userPerms.includes(perm)) { + return true; + } + } + // noinspection RedundantIfStatementJS + if (userPerms.includes("SuperUser")) { + return true; + } + return false; +} + +async function _resolvePermissionsForUser(userID: number) { + const result = await prisma.rolePermission.findMany({ + where: { + roles: { + role_members: { + some: { + user_id: userID, + }, + }, + }, + }, + select: { + permission: true, + }, + }); + return result.map((r) => r.permission as Permission); +} + +// Since this file may be imported from the "standalone server" context, not Next, +// React cache may not be available. Use the presence of AsyncLocalStorage as a +// dumb shibboleth. +export let resolvePermissionsForUser: typeof _resolvePermissionsForUser; +if (globalThis.AsyncLocalStorage) { + resolvePermissionsForUser = cache(_resolvePermissionsForUser); +} else { + resolvePermissionsForUser = _resolvePermissionsForUser; +} diff --git a/lib/auth/permissions.ts b/lib/auth/permissions.ts index 426fc811..a1720496 100644 --- a/lib/auth/permissions.ts +++ b/lib/auth/permissions.ts @@ -22,6 +22,8 @@ export const PermissionEnum = z.enum([ "Calendar.Public.Admin", "Calendar.Public.Creator", "CalendarIntegration.Admin", + "CheckWithTech.Submit", + "CheckWithTech.Admin", "ManageQuotes", "Admin.Users", "Admin.Roles", diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 2d7c03b8..9ba23a0a 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -2,8 +2,8 @@ import "server-only"; import { prisma } from "@/lib/db"; import { Forbidden, NotLoggedIn } from "./errors"; import { Permission } from "./permissions"; -import { Identity, User } from "@prisma/client"; -import { NextRequest, NextResponse } from "next/server"; +import { Identity } from "@prisma/client"; +import { NextRequest } from "next/server"; import { findOrCreateUserFromGoogleToken } from "./google"; import { redirect } from "next/navigation"; import { z } from "zod"; @@ -12,34 +12,9 @@ import { SlackTokenJson, findOrCreateUserFromSlackToken } from "./slack"; import { env } from "../env"; import { RequestCookie } from "next/dist/compiled/@edge-runtime/cookies"; import { cache } from "react"; +import { UserType, resolvePermissionsForUser, userHasPermission } from "./core"; -export interface UserWithIdentities extends User { - identities: Identity[]; -} - -export type UserType = UserWithIdentities & { - permissions: Permission[]; -}; - -const resolvePermissionsForUser = cache( - async function resolvePermissionsForUser(userID: number) { - const result = await prisma.rolePermission.findMany({ - where: { - roles: { - role_members: { - some: { - user_id: userID, - }, - }, - }, - }, - select: { - permission: true, - }, - }); - return result.map((r) => r.permission as Permission); - }, -); +export * from "./core"; /** * Ensures that the currently signed-in user has at least one of the given permissions, @@ -166,17 +141,7 @@ export async function ensureNoActiveSession( */ export async function hasPermission(...perms: Permission[]): Promise { const user = await getCurrentUser(); - const userPerms = await resolvePermissionsForUser(user.user_id); - for (const perm of perms) { - if (userPerms.includes(perm)) { - return true; - } - } - // noinspection RedundantIfStatementJS - if (userPerms.includes("SuperUser")) { - return true; - } - return false; + return await userHasPermission(user, ...perms); } export async function loginOrCreateUserGoogle(rawGoogleToken: string) { diff --git a/lib/db/migrations/20241007154030_check_with_tech_data/migration.sql b/lib/db/migrations/20241007154030_check_with_tech_data/migration.sql new file mode 100644 index 00000000..f73a3595 --- /dev/null +++ b/lib/db/migrations/20241007154030_check_with_tech_data/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `check_with_tech_status` on the `events` table. All the data in the column will be lost. + +*/ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "CheckWithTechStatus" ADD VALUE 'Confirmed'; +ALTER TYPE "CheckWithTechStatus" ADD VALUE 'Rejected'; + +-- CreateTable +CREATE TABLE "CheckWithTech" ( + "cwt_id" SERIAL NOT NULL, + "event_id" INTEGER NOT NULL, + "submitted_by" INTEGER NOT NULL, + "status" "CheckWithTechStatus" NOT NULL DEFAULT 'Requested', + "request" TEXT NOT NULL, + "notes" TEXT NOT NULL DEFAULT '', + "confirmed_by" INTEGER, + "confirmed_at" TIMESTAMPTZ(6), + + CONSTRAINT "CheckWithTech_pkey" PRIMARY KEY ("cwt_id") +); + +-- AddForeignKey +ALTER TABLE "CheckWithTech" ADD CONSTRAINT "CheckWithTech_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("event_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CheckWithTech" ADD CONSTRAINT "CheckWithTech_submitted_by_fkey" FOREIGN KEY ("submitted_by") REFERENCES "users"("user_id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "CheckWithTech" ADD CONSTRAINT "CheckWithTech_confirmed_by_fkey" FOREIGN KEY ("confirmed_by") REFERENCES "users"("user_id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- continued in pt2 migration diff --git a/lib/db/migrations/20241007183129_check_with_tech_data_pt2/migration.sql b/lib/db/migrations/20241007183129_check_with_tech_data_pt2/migration.sql new file mode 100644 index 00000000..1ad9338b --- /dev/null +++ b/lib/db/migrations/20241007183129_check_with_tech_data_pt2/migration.sql @@ -0,0 +1,8 @@ +-- migrate data +INSERT INTO "CheckWithTech" ("event_id", "submitted_by", "status", "request") +SELECT "event_id", "host", 'Confirmed', '[Automatically migrated. Please check Slack.]' +FROM "events" +WHERE "check_with_tech_status" IS NOT NULL; + +-- AlterTable +ALTER TABLE "events" DROP COLUMN "check_with_tech_status"; diff --git a/lib/db/migrations/20241007190010_add_cwt_submitted_at/migration.sql b/lib/db/migrations/20241007190010_add_cwt_submitted_at/migration.sql new file mode 100644 index 00000000..89afb4fd --- /dev/null +++ b/lib/db/migrations/20241007190010_add_cwt_submitted_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CheckWithTech" ADD COLUMN "submitted_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/lib/db/migrations/20241007193533_cwt_add_message/migration.sql b/lib/db/migrations/20241007193533_cwt_add_message/migration.sql new file mode 100644 index 00000000..26e59277 --- /dev/null +++ b/lib/db/migrations/20241007193533_cwt_add_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CheckWithTech" ADD COLUMN "slack_message_ts" TEXT; diff --git a/lib/db/migrations/20241008202912_cwt_add_uncertain/migration.sql b/lib/db/migrations/20241008202912_cwt_add_uncertain/migration.sql new file mode 100644 index 00000000..269fefe7 --- /dev/null +++ b/lib/db/migrations/20241008202912_cwt_add_uncertain/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CheckWithTech" ADD COLUMN "unsure" BOOLEAN NOT NULL DEFAULT false; diff --git a/lib/db/schema.prisma b/lib/db/schema.prisma index 2ff23336..82dfa49b 100644 --- a/lib/db/schema.prisma +++ b/lib/db/schema.prisma @@ -71,7 +71,9 @@ model User { hosted_events Event[] @relation("event_host_user") /// [UserPreferences] - preferences Json @default("{}") + preferences Json @default("{}") + check_with_tech_confirmed CheckWithTech[] @relation("confirmed") + check_with_tech_submitted CheckWithTech[] @relation("submitted") @@map("users") } @@ -118,39 +120,62 @@ model Crew { /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments model Event { - event_id Int @id @default(autoincrement()) - event_type String @default("other") - name String - start_date DateTime @db.Timestamptz(6) - end_date DateTime @db.Timestamptz(6) - description String @default("") - location String @default("") - is_private Boolean @default(false) - is_cancelled Boolean @default(false) - is_tentative Boolean @default(false) - created_at DateTime @default(now()) @db.Timestamptz(6) - created_by Int - updated_at DateTime? @db.Timestamptz(6) - updated_by Int? - deleted_at DateTime? @db.Timestamptz(6) - deleted_by Int? - host Int - adam_rms_project_id Int? - slack_channel_id String @default("") - check_with_tech_status CheckWithTechStatus? + event_id Int @id @default(autoincrement()) + event_type String @default("other") + name String + start_date DateTime @db.Timestamptz(6) + end_date DateTime @db.Timestamptz(6) + description String @default("") + location String @default("") + is_private Boolean @default(false) + is_cancelled Boolean @default(false) + is_tentative Boolean @default(false) + created_at DateTime @default(now()) @db.Timestamptz(6) + created_by Int + updated_at DateTime? @db.Timestamptz(6) + updated_by Int? + deleted_at DateTime? @db.Timestamptz(6) + deleted_by Int? + host Int + adam_rms_project_id Int? + slack_channel_id String @default("") attendees Attendee[] - created_by_user User @relation("events_created_byTousers", fields: [created_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) - deleted_by_user User? @relation("events_deleted_byTousers", fields: [deleted_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) - updated_by_user User? @relation("events_updated_byTousers", fields: [updated_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) - host_user User @relation("event_host_user", fields: [host], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + created_by_user User @relation("events_created_byTousers", fields: [created_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + deleted_by_user User? @relation("events_deleted_byTousers", fields: [deleted_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + updated_by_user User? @relation("events_updated_byTousers", fields: [updated_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + host_user User @relation("event_host_user", fields: [host], references: [user_id], onDelete: NoAction, onUpdate: NoAction) signup_sheets SignupSheet[] + check_with_tech CheckWithTech[] @@map("events") } +model CheckWithTech { + cwt_id Int @id @default(autoincrement()) + event_id Int + event Event @relation(fields: [event_id], references: [event_id], onDelete: Cascade) + + submitted_by Int + submitted_by_user User @relation("submitted", fields: [submitted_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + submitted_at DateTime @default(now()) @db.Timestamptz(6) + + status CheckWithTechStatus @default(Requested) + request String + notes String @default("") + unsure Boolean @default(false) + + slack_message_ts String? + + confirmed_by Int? + confirmed_by_user User? @relation("confirmed", fields: [confirmed_by], references: [user_id], onDelete: NoAction, onUpdate: NoAction) + confirmed_at DateTime? @db.Timestamptz(6) +} + enum CheckWithTechStatus { Requested + Confirmed + Rejected } /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. diff --git a/lib/db/types/checkwithtech.ts b/lib/db/types/checkwithtech.ts new file mode 100644 index 00000000..82572c54 --- /dev/null +++ b/lib/db/types/checkwithtech.ts @@ -0,0 +1,34 @@ +import * as z from "zod" +import { CheckWithTechStatus } from "@prisma/client" +import { CompleteEvent, EventModel, CompleteUser, UserModel } from "./index" + +export const _CheckWithTechModel = z.object({ + cwt_id: z.number().int(), + event_id: z.number().int(), + submitted_by: z.number().int(), + submitted_at: z.date(), + status: z.nativeEnum(CheckWithTechStatus), + request: z.string(), + notes: z.string(), + unsure: z.boolean(), + slack_message_ts: z.string().nullish(), + confirmed_by: z.number().int().nullish(), + confirmed_at: z.date().nullish(), +}) + +export interface CompleteCheckWithTech extends z.infer { + event: CompleteEvent + submitted_by_user: CompleteUser + confirmed_by_user?: CompleteUser | null +} + +/** + * CheckWithTechModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const CheckWithTechModel: z.ZodSchema = z.lazy(() => _CheckWithTechModel.extend({ + event: EventModel, + submitted_by_user: UserModel, + confirmed_by_user: UserModel.nullish(), +})) diff --git a/lib/db/types/event.ts b/lib/db/types/event.ts index ee7515ca..83342d7a 100644 --- a/lib/db/types/event.ts +++ b/lib/db/types/event.ts @@ -1,6 +1,5 @@ import * as z from "zod" -import { CheckWithTechStatus } from "@prisma/client" -import { CompleteAttendee, AttendeeModel, CompleteUser, UserModel, CompleteSignupSheet, SignupSheetModel } from "./index" +import { CompleteAttendee, AttendeeModel, CompleteUser, UserModel, CompleteSignupSheet, SignupSheetModel, CompleteCheckWithTech, CheckWithTechModel } from "./index" export const _EventModel = z.object({ event_id: z.number().int(), @@ -22,7 +21,6 @@ export const _EventModel = z.object({ host: z.number().int(), adam_rms_project_id: z.number().int().nullish(), slack_channel_id: z.string(), - check_with_tech_status: z.nativeEnum(CheckWithTechStatus).nullish(), }) export interface CompleteEvent extends z.infer { @@ -32,6 +30,7 @@ export interface CompleteEvent extends z.infer { updated_by_user?: CompleteUser | null host_user: CompleteUser signup_sheets: CompleteSignupSheet[] + check_with_tech: CompleteCheckWithTech[] } /** @@ -46,4 +45,5 @@ export const EventModel: z.ZodSchema = z.lazy(() => _EventModel.e updated_by_user: UserModel.nullish(), host_user: UserModel, signup_sheets: SignupSheetModel.array(), + check_with_tech: CheckWithTechModel.array(), })) diff --git a/lib/db/types/index.ts b/lib/db/types/index.ts index a9fe1205..345f923a 100644 --- a/lib/db/types/index.ts +++ b/lib/db/types/index.ts @@ -6,6 +6,7 @@ export * from "./identity" export * from "./attendee" export * from "./crew" export * from "./event" +export * from "./checkwithtech" export * from "./position" export * from "./signupsheet" export * from "./quote" diff --git a/lib/db/types/user.ts b/lib/db/types/user.ts index d52c8648..75039eb2 100644 --- a/lib/db/types/user.ts +++ b/lib/db/types/user.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import { CompleteIdentity, IdentityModel, CompleteAttendee, AttendeeModel, CompleteCrew, CrewModel, CompleteEvent, EventModel, CompleteRoleMember, RoleMemberModel } from "./index" +import { CompleteIdentity, IdentityModel, CompleteAttendee, AttendeeModel, CompleteCrew, CrewModel, CompleteEvent, EventModel, CompleteRoleMember, RoleMemberModel, CompleteCheckWithTech, CheckWithTechModel } from "./index" // Helper schema for JSON fields type Literal = boolean | number | string @@ -30,6 +30,8 @@ export interface CompleteUser extends z.infer { events_events_updated_byTousers: CompleteEvent[] role_members: CompleteRoleMember[] hosted_events: CompleteEvent[] + check_with_tech_confirmed: CompleteCheckWithTech[] + check_with_tech_submitted: CompleteCheckWithTech[] } /** @@ -46,4 +48,6 @@ export const UserModel: z.ZodSchema = z.lazy(() => _UserModel.exte events_events_updated_byTousers: EventModel.array(), role_members: RoleMemberModel.array(), hosted_events: EventModel.array(), + check_with_tech_confirmed: CheckWithTechModel.array(), + check_with_tech_submitted: CheckWithTechModel.array(), })) diff --git a/lib/env.ts b/lib/env.ts index 86c0871a..61206876 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -36,8 +36,7 @@ const envSchema = z.object({ SLACK_CLIENT_ID: slackEnvType, SLACK_CLIENT_SECRET: slackEnvType, SLACK_TEAM_ID: z.string().optional(), - SLACK_CHECK_WITH_TECH_CHANNEL: slackEnvType.default("#check-with-tech"), - SLACK_TECH_HELP_CHANNEL: slackEnvType.default("#check-with-tech"), + SLACK_CHECK_WITH_TECH_CHANNEL: slackEnvType, SLACK_USER_FEEDBACK_CHANNEL: slackEnvType.default("#dev-calendar"), DEV_SSL: z.string().optional(), // Used to decide whether or not to use https in a dev environment SENTRY_PROJECT_ID: z.string().optional(), diff --git a/lib/slack/actions.ts b/lib/slack/actions.ts new file mode 100644 index 00000000..9b28f23d --- /dev/null +++ b/lib/slack/actions.ts @@ -0,0 +1,16 @@ +import * as CheckWithTech from "@/features/calendar/check_with_tech_actions"; +import { App } from "@slack/bolt"; + +export async function setupActionHandlers(app: App) { + // Check With Tech + app.action(/^checkWithTech#.+/, async (action) => { + await CheckWithTech.handleSlackAction(action); + }); + app.view(/^checkWithTech#.+/, async (action) => { + await CheckWithTech.handleSlackViewEvent(action); + }); + + app.action("userFeedback#searchSentry", async ({ ack }) => { + await ack(); // no-op + }); +} diff --git a/lib/slack/slackApiConnection.ts b/lib/slack/slackApiConnection.ts index 3a85242b..51885e4d 100644 --- a/lib/slack/slackApiConnection.ts +++ b/lib/slack/slackApiConnection.ts @@ -8,6 +8,9 @@ declare global { export const isSlackEnabled = env.SLACK_ENABLED === "true"; async function slackApiConnection() { + if (!isSlackEnabled) { + throw new Error("Slack is not enabled"); + } if (!global.slack) { global.slack = new App({ token: env.SLACK_BOT_TOKEN, diff --git a/server/index.ts b/server/index.ts index 6e090c74..21402f6b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,6 +7,7 @@ import slackApiConnection, { isSlackEnabled, } from "../lib/slack/slackApiConnection"; import { App } from "@slack/bolt"; +import { setupActionHandlers } from "@/lib/slack/actions"; import { checkDatabaseConnection, prepareHttpServer } from "./lib"; const dev = env.NODE_ENV !== "production"; @@ -34,7 +35,7 @@ app.prepare().then(async () => { if (isSlackEnabled) { slackApp = await slackApiConnection(); - slackApp.action("user_feedback__search_sentry", async ({ ack }) => ack()); + await setupActionHandlers(slackApp); } io = new Server(httpServer);