feat: slack channel suggestions

This commit is contained in:
Sheen Capadngan
2024-09-04 18:03:07 +08:00
parent 15c5fe4095
commit 71b8c59050
10 changed files with 284 additions and 78 deletions
+11 -9
View File
@@ -576,16 +576,18 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
projectId: req.params.workspaceId projectId: req.params.workspaceId
}); });
await server.services.auditLog.createAuditLog({ if (slackConfig) {
...req.auditLogInfo, await server.services.auditLog.createAuditLog({
projectId: req.params.workspaceId, ...req.auditLogInfo,
event: { projectId: req.params.workspaceId,
type: EventType.GET_PROJECT_SLACK_CONFIG, event: {
metadata: { type: EventType.GET_PROJECT_SLACK_CONFIG,
id: slackConfig.id metadata: {
id: slackConfig.id
}
} }
} });
}); }
return slackConfig; return slackConfig;
} }
@@ -232,6 +232,44 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/:slackIntegrationId/channels",
config: {
rateLimit: readLimit
},
schema: {
security: [
{
bearerAuth: []
}
],
params: z.object({
slackIntegrationId: z.string()
}),
response: {
200: z
.object({
name: z.string(),
id: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const slackChannels = await server.services.slack.getSlackIntegrationChannels({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.slackIntegrationId
});
return slackChannels;
}
});
server.route({ server.route({
method: "PATCH", method: "PATCH",
url: "/:slackIntegrationId", url: "/:slackIntegrationId",
+46 -32
View File
@@ -1,11 +1,44 @@
import { Block, WebClient } from "@slack/web-api"; import { Block, WebClient } from "@slack/web-api";
import { logger } from "@app/lib/logger";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal";
import { SlackTriggerFeature } from "./slack-types"; import { SlackTriggerFeature } from "./slack-types";
export const fetchSlackChannels = async (botKey: string) => {
const slackChannels: {
name: string;
id: string;
}[] = [];
const slackWebClient = new WebClient(botKey);
let cursor;
do {
// eslint-disable-next-line no-await-in-loop
const response = await slackWebClient.conversations.list({
cursor,
limit: 1000,
types: "public_channel,private_channel"
});
response.channels?.forEach((channel) =>
slackChannels.push({
name: channel.name_normalized as string,
id: channel.id as string
})
);
// Set the cursor for the next page
cursor = response.response_metadata?.next_cursor;
} while (cursor); // Continue while there is a cursor
return slackChannels;
};
export const triggerSlackNotification = async ({ export const triggerSlackNotification = async ({
projectId, projectId,
payloadBlocks, payloadBlocks,
@@ -30,15 +63,15 @@ export const triggerSlackNotification = async ({
return; return;
} }
let targetChannels: string[] = []; let targetChannelIds: string[] = [];
if (feature === SlackTriggerFeature.ACCESS_REQUEST) { if (feature === SlackTriggerFeature.ACCESS_REQUEST) {
targetChannels = slackIntegration.accessRequestChannels?.split(", ") || []; targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || [];
if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) {
return; return;
} }
} else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) {
targetChannels = slackIntegration.secretRequestChannels?.split(", ") || []; targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || [];
if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { if (!targetChannelIds.length || !slackIntegration.isSecretRequestNotificationEnabled) {
return; return;
} }
} }
@@ -52,35 +85,16 @@ export const triggerSlackNotification = async ({
cipherTextBlob: slackIntegration.encryptedBotAccessToken cipherTextBlob: slackIntegration.encryptedBotAccessToken
}).toString("utf8"); }).toString("utf8");
const targetChannelSet = new Set<string>(targetChannels);
const slackWebClient = new WebClient(botKey); const slackWebClient = new WebClient(botKey);
const channelIdsToSendNotif: string[] = [];
let cursor;
do { for await (const conversationId of targetChannelIds) {
// eslint-disable-next-line no-await-in-loop
const response = await slackWebClient.conversations.list({
cursor,
limit: 1000,
types: "public_channel,private_channel"
});
response.channels?.forEach((channel) => {
if (channel.name_normalized && targetChannelSet.has(channel.name_normalized)) {
channelIdsToSendNotif.push(channel.id as string);
}
});
// Set the cursor for the next page
cursor = response.response_metadata?.next_cursor;
} while (cursor); // Continue while there is a cursor
for await (const conversationId of channelIdsToSendNotif) {
// we send both text and blocks for compatibility with barebone clients // we send both text and blocks for compatibility with barebone clients
await slackWebClient.chat.postMessage({ await slackWebClient.chat
channel: conversationId, .postMessage({
text: payloadMessage, channel: conversationId,
blocks: payloadBlocks text: payloadMessage,
}); blocks: payloadBlocks
})
.catch((err) => void logger.error(err));
} }
}; };
+40 -1
View File
@@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { fetchSlackChannels } from "./slack-fns";
import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal";
import { import {
TCompleteSlackIntegrationDTO, TCompleteSlackIntegrationDTO,
@@ -16,6 +17,7 @@ import {
TGetSlackInstallUrlDTO, TGetSlackInstallUrlDTO,
TGetSlackIntegrationByIdDTO, TGetSlackIntegrationByIdDTO,
TGetSlackIntegrationByOrgDTO, TGetSlackIntegrationByOrgDTO,
TGetSlackIntegrationChannelsDTO,
TReinstallSlackIntegrationDTO, TReinstallSlackIntegrationDTO,
TUpdateSlackIntegrationDTO TUpdateSlackIntegrationDTO
} from "./slack-types"; } from "./slack-types";
@@ -285,6 +287,42 @@ export const slackServiceFactory = ({
return slackIntegration; return slackIntegration;
}; };
const getSlackIntegrationChannels = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
id
}: TGetSlackIntegrationChannelsDTO) => {
const slackIntegration = await slackIntegrationDAL.findById(id);
if (!slackIntegration) {
throw new NotFoundError({
message: "Slack integration not found."
});
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
slackIntegration.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({
orgId: slackIntegration.orgId,
type: KmsDataKey.Organization
});
const botKey = orgDataKeyDecryptor({
cipherTextBlob: slackIntegration.encryptedBotAccessToken
}).toString("utf8");
return fetchSlackChannels(botKey);
};
const updateSlackIntegration = async ({ const updateSlackIntegration = async ({
actorId, actorId,
actor, actor,
@@ -352,6 +390,7 @@ export const slackServiceFactory = ({
completeSlackIntegration, completeSlackIntegration,
getSlackInstaller, getSlackInstaller,
updateSlackIntegration, updateSlackIntegration,
deleteSlackIntegration deleteSlackIntegration,
getSlackIntegrationChannels
}; };
}; };
@@ -13,6 +13,8 @@ export type TGetSlackIntegrationByOrgDTO = Omit<TOrgPermission, "orgId">;
export type TGetSlackIntegrationByIdDTO = { id: string } & Omit<TOrgPermission, "orgId">; export type TGetSlackIntegrationByIdDTO = { id: string } & Omit<TOrgPermission, "orgId">;
export type TGetSlackIntegrationChannelsDTO = { id: string } & Omit<TOrgPermission, "orgId">;
export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit<
TOrgPermission, TOrgPermission,
"orgId" "orgId"
@@ -7,5 +7,6 @@ export {
fetchSlackInstallUrl, fetchSlackInstallUrl,
fetchSlackReinstallUrl, fetchSlackReinstallUrl,
useGetSlackIntegrationById, useGetSlackIntegrationById,
useGetSlackIntegrationChannels,
useGetSlackIntegrations useGetSlackIntegrations
} from "./queries"; } from "./queries";
@@ -20,8 +20,8 @@ export const useUpdateSlackIntegration = () => {
return data; return data;
}, },
onSuccess: (_, { orgId, id }) => { onSuccess: (_, { orgId, id }) => {
queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id));
queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId));
} }
}); });
}; };
@@ -36,8 +36,8 @@ export const useDeleteSlackIntegration = () => {
return data; return data;
}, },
onSuccess: (_, { orgId, id }) => { onSuccess: (_, { orgId, id }) => {
queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id));
queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId));
} }
}); });
}; };
@@ -2,11 +2,12 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { SlackIntegration } from "./types"; import { SlackIntegration, SlackIntegrationChannel } from "./types";
export const workflowIntegrationKeys = { export const workflowIntegrationKeys = {
getSlackWorkflowIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"],
getSlackWorkflowIntegration: (id?: string) => [{ id }, "slack-workflow-integration"] getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"],
getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"]
}; };
export const fetchSlackInstallUrl = async ({ export const fetchSlackInstallUrl = async ({
@@ -54,16 +55,31 @@ export const fetchSlackIntegrationById = async (id?: string) => {
return data; return data;
}; };
export const fetchSlackIntegrationChannels = async (id?: string) => {
const { data } = await apiRequest.get<SlackIntegrationChannel[]>(
`/api/v1/workflow-integrations/slack/${id}/channels`
);
return data;
};
export const useGetSlackIntegrations = (orgId?: string) => export const useGetSlackIntegrations = (orgId?: string) =>
useQuery({ useQuery({
queryKey: workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId), queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId),
queryFn: () => fetchSlackIntegrations(), queryFn: () => fetchSlackIntegrations(),
enabled: Boolean(orgId) enabled: Boolean(orgId)
}); });
export const useGetSlackIntegrationById = (id?: string) => export const useGetSlackIntegrationById = (id?: string) =>
useQuery({ useQuery({
queryKey: workflowIntegrationKeys.getSlackWorkflowIntegration(id), queryKey: workflowIntegrationKeys.getSlackIntegration(id),
queryFn: () => fetchSlackIntegrationById(id), queryFn: () => fetchSlackIntegrationById(id),
enabled: Boolean(id) enabled: Boolean(id)
}); });
export const useGetSlackIntegrationChannels = (id?: string) =>
useQuery({
queryKey: workflowIntegrationKeys.getSlackIntegrationChannels(id),
queryFn: () => fetchSlackIntegrationChannels(id),
enabled: Boolean(id)
});
@@ -9,6 +9,11 @@ export type SlackIntegration = {
teamName: string; teamName: string;
}; };
export type SlackIntegrationChannel = {
id: string;
name: string;
};
export type TUpdateSlackIntegrationDTO = { export type TUpdateSlackIntegrationDTO = {
id: string; id: string;
orgId: string; orgId: string;
@@ -1,6 +1,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import Link from "next/link"; import Link from "next/link";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -9,6 +11,10 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
ContentLoader, ContentLoader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState, EmptyState,
FormControl, FormControl,
Input, Input,
@@ -18,6 +24,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { import {
useGetSlackIntegrationChannels,
useGetSlackIntegrations, useGetSlackIntegrations,
useGetWorkspaceSlackConfig, useGetWorkspaceSlackConfig,
useUpdateProjectSlackConfig useUpdateProjectSlackConfig
@@ -26,9 +33,9 @@ import {
const formSchema = z.object({ const formSchema = z.object({
slackIntegrationId: z.string(), slackIntegrationId: z.string(),
isSecretRequestNotificationEnabled: z.boolean(), isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().default(""), secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(), isAccessRequestNotificationEnabled: z.boolean(),
accessRequestChannels: z.string().default("") accessRequestChannels: z.string().array()
}); });
type TSlackConfigForm = z.infer<typeof formSchema>; type TSlackConfigForm = z.infer<typeof formSchema>;
@@ -51,9 +58,9 @@ export const WorkflowIntegrationTab = () => {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
isAccessRequestNotificationEnabled: false, isAccessRequestNotificationEnabled: false,
accessRequestChannels: "", accessRequestChannels: [],
isSecretRequestNotificationEnabled: false, isSecretRequestNotificationEnabled: false,
secretRequestChannels: "" secretRequestChannels: []
} }
}); });
@@ -61,6 +68,14 @@ export const WorkflowIntegrationTab = () => {
const selectedSlackIntegrationId = watch("slackIntegrationId"); const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
const sortedSlackChannels = slackChannels?.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
const handleIntegrationSave = async (data: TSlackConfigForm) => { const handleIntegrationSave = async (data: TSlackConfigForm) => {
if (!currentWorkspace) { if (!currentWorkspace) {
return; return;
@@ -68,7 +83,9 @@ export const WorkflowIntegrationTab = () => {
await updateProjectSlackConfig({ await updateProjectSlackConfig({
workspaceId: currentWorkspace.id, workspaceId: currentWorkspace.id,
...data ...data,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
}); });
createNotification({ createNotification({
@@ -84,14 +101,27 @@ export const WorkflowIntegrationTab = () => {
"isSecretRequestNotificationEnabled", "isSecretRequestNotificationEnabled",
slackConfig.isSecretRequestNotificationEnabled slackConfig.isSecretRequestNotificationEnabled
); );
setValue("secretRequestChannels", slackConfig.secretRequestChannels);
setValue( setValue(
"isAccessRequestNotificationEnabled", "isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled slackConfig.isAccessRequestNotificationEnabled
); );
setValue("accessRequestChannels", slackConfig.accessRequestChannels);
if (slackChannels) {
setValue(
"secretRequestChannels",
slackConfig.secretRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"accessRequestChannels",
slackConfig.accessRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
}
} }
}, [slackConfig]); }, [slackConfig, slackChannels]);
if (isSlackConfigLoading) { if (isSlackConfigLoading) {
return <ContentLoader />; return <ContentLoader />;
@@ -173,19 +203,49 @@ export const WorkflowIntegrationTab = () => {
<Controller <Controller
control={control} control={control}
name="secretRequestChannels" name="secretRequestChannels"
render={({ field, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Slack channels" label="Slack channels"
errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
isRequired={false} errorText={error?.message}
> >
<Input <DropdownMenu>
autoCorrect="off" <DropdownMenuTrigger asChild>
spellCheck={false} <Input
placeholder="general, bot" isReadOnly
{...field} value={value
/> ?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`secret-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />
@@ -211,20 +271,49 @@ export const WorkflowIntegrationTab = () => {
<Controller <Controller
control={control} control={control}
name="accessRequestChannels" name="accessRequestChannels"
render={({ field, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Slack channels" label="Slack channels"
className="mt-0"
errorText={error?.message}
isError={Boolean(error)} isError={Boolean(error)}
isRequired={false} errorText={error?.message}
> >
<Input <DropdownMenu>
autoCorrect="off" <DropdownMenuTrigger asChild>
spellCheck={false} <Input
placeholder="general, bot" isReadOnly
{...field} value={value
/> ?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`access-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />