From 58b150ee046ee7dcea1c4c20a388fdc2fd7eb103 Mon Sep 17 00:00:00 2001 From: Salihu Date: Fri, 6 Mar 2026 22:36:19 +0100 Subject: [PATCH] feat: slack-notifications --- .env.sample | 1 + .env.test | 1 + app/models/User.ts | 63 +++- app/scenes/Settings/Notifications.tsx | 71 +++- .../Settings/components/ChannelSelector.tsx | 58 ++++ patches/@chat-adapter+shared+4.15.0.patch | 21 ++ patches/@chat-adapter+slack+4.15.0.patch | 21 ++ patches/chat+4.15.0.patch | 33 ++ plugins/slack/server/auth/slack.ts | 41 ++- plugins/slack/server/env.ts | 6 + plugins/slack/server/slack.ts | 41 +++ ...0000-add-slack-sent-at-to-notifications.js | 26 ++ server/models/Notification.ts | 4 + server/models/User.ts | 95 ++++- server/queues/processors/SlackProcessor.ts | 183 ++++++++++ server/routes/api/users/schema.ts | 1 + server/routes/api/users/users.ts | 4 +- yarn.lock | 326 +++++++++++++++++- 18 files changed, 941 insertions(+), 55 deletions(-) create mode 100644 app/scenes/Settings/components/ChannelSelector.tsx create mode 100644 patches/@chat-adapter+shared+4.15.0.patch create mode 100644 patches/@chat-adapter+slack+4.15.0.patch create mode 100644 patches/chat+4.15.0.patch create mode 100644 server/migrations/20260211000000-add-slack-sent-at-to-notifications.js create mode 100644 server/queues/processors/SlackProcessor.ts diff --git a/.env.sample b/.env.sample index 30e1044124..27b94c6f71 100644 --- a/.env.sample +++ b/.env.sample @@ -152,6 +152,7 @@ FORCE_HTTPS=true # DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J SLACK_CLIENT_ID=get_a_key_from_slack SLACK_CLIENT_SECRET=get_the_secret_of_above_key +SLACK_SIGNING_SECRET=get_the_signing_secret_from_slack # Google sign-in provider # DOCS: https://docs.getoutline.com/s/hosting/doc/google-hOuvtCmTqQ diff --git a/.env.test b/.env.test index eb012561a1..ec84a762ac 100644 --- a/.env.test +++ b/.env.test @@ -13,6 +13,7 @@ GOOGLE_CLIENT_SECRET=123 SLACK_CLIENT_ID=123 SLACK_CLIENT_SECRET=123 SLACK_VERIFICATION_TOKEN=test-token-123 +SLACK_SIGNING_SECRET=test-signing-secret-123 GITHUB_CLIENT_ID=123; GITHUB_CLIENT_SECRET=123; diff --git a/app/models/User.ts b/app/models/User.ts index 63fbc77e3a..80ce66c613 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -3,6 +3,7 @@ import { computed, action, observable } from "mobx"; import { now } from "mobx-utils"; import { UserPreferenceDefaults } from "@shared/constants"; import { + NotificationChannelType, NotificationEventDefaults, type NotificationEventType, TeamPreference, @@ -206,36 +207,76 @@ class User extends ParanoidModel implements Searchable { * Returns the current preference for the given notification event type taking * into account the default system value. * - * @param type The type of notification event - * @returns The current preference + * @param type The type of notification event. + * @param channel Optional channel type for channel-specific check. + * @returns The current preference. */ - public subscribedToEventType = (type: NotificationEventType) => - this.notificationSettings[type] ?? NotificationEventDefaults[type] ?? false; + public subscribedToEventType = ( + type: NotificationEventType, + channel = NotificationChannelType.Email + ): boolean => { + const setting = this.notificationSettings[type]; + const defaultValue = NotificationEventDefaults[type] ?? false; + + if (setting === undefined) { + return defaultValue; + } + + if (typeof setting === "boolean") { + return setting; + } + + if (typeof setting === "object") { + return setting[channel] ?? defaultValue; + } + + return defaultValue; + }; /** * Sets a preference for the users notification settings on the model and * saves the change to the server. * - * @param type The type of notification event - * @param value Set the preference to true/false + * @param type The type of notification event. + * @param value Set the preference to true/false. + * @param channel Optional channel type for channel-specific settings. */ @action setNotificationEventType = async ( eventType: NotificationEventType, - value: boolean + value: boolean | Record, + channel?: NotificationChannelType ) => { - this.notificationSettings = { - ...this.notificationSettings, - [eventType]: value, - }; + if (channel !== undefined) { + // Setting a specific channel preference + const currentSetting = this.notificationSettings[eventType]; + const channelSettings = + typeof currentSetting === "object" ? currentSetting : {}; + + this.notificationSettings = { + ...this.notificationSettings, + [eventType]: { + ...channelSettings, + [channel]: value, + }, + }; + } else { + // Setting all channels or simple boolean + this.notificationSettings = { + ...this.notificationSettings, + [eventType]: value, + }; + } if (value) { await client.post(`/users.notificationsSubscribe`, { eventType, + channel, }); } else { await client.post(`/users.notificationsUnsubscribe`, { eventType, + channel, }); } }; diff --git a/app/scenes/Settings/Notifications.tsx b/app/scenes/Settings/Notifications.tsx index 65ed71a83b..c8fadd969a 100644 --- a/app/scenes/Settings/Notifications.tsx +++ b/app/scenes/Settings/Notifications.tsx @@ -18,16 +18,23 @@ import { } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; -import { NotificationEventType } from "@shared/types"; +import { + NotificationEventType, + NotificationChannelType, + IntegrationService, + IntegrationType, +} from "@shared/types"; import Heading from "~/components/Heading"; import Notice from "~/components/Notice"; import Scene from "~/components/Scene"; -import Switch from "~/components/Switch"; import Text from "~/components/Text"; import useCurrentUser from "~/hooks/useCurrentUser"; import { client } from "~/utils/ApiClient"; import isCloudHosted from "~/utils/isCloudHosted"; +import { settingsPath } from "~/utils/routeHelpers"; +import ChannelSelector from "./components/ChannelSelector"; import SettingRow from "./components/SettingRow"; function Notifications() { @@ -161,11 +168,29 @@ function Notifications() { toast.success(t("Notifications saved")); }, 500); - const handleChange = React.useCallback( - (eventType: NotificationEventType) => async (checked: boolean) => { - await user.setNotificationEventType(eventType, checked); - showSuccessMessage(); - }, + const handleChannelsChange = React.useCallback( + (eventType: NotificationEventType) => + async (channels: NotificationChannelType[]) => { + for (const channel of [ + NotificationChannelType.Email, + NotificationChannelType.Slack, + ]) { + const shouldEnable = channels.includes(channel); + const currentlyEnabled = user.subscribedToEventType( + eventType, + channel + ); + + if (shouldEnable !== currentlyEnabled) { + await user.setNotificationEventType( + eventType, + shouldEnable, + channel + ); + } + } + showSuccessMessage(); + }, [user, showSuccessMessage] ); @@ -206,7 +231,10 @@ function Notifications() { )} - Manage when and where you receive email notifications. + + Manage when and where you receive notifications. Choose to receive + notifications via email, Slack, or both. + {options.map((option) => { - const setting = user.subscribedToEventType(option.event); + const emailSetting = user.subscribedToEventType( + option.event, + NotificationChannelType.Email + ); + const slackSetting = user.subscribedToEventType( + option.event, + NotificationChannelType.Slack + ); + + const enabledChannels: NotificationChannelType[] = []; + if (emailSetting) { + enabledChannels.push(NotificationChannelType.Email); + } + if (slackSetting) { + enabledChannels.push(NotificationChannelType.Slack); + } return ( - ); diff --git a/app/scenes/Settings/components/ChannelSelector.tsx b/app/scenes/Settings/components/ChannelSelector.tsx new file mode 100644 index 0000000000..62bf63a872 --- /dev/null +++ b/app/scenes/Settings/components/ChannelSelector.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { EmailIcon } from "outline-icons"; +import { useTranslation } from "react-i18next"; +import { NotificationChannelType } from "@shared/types"; +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FilterOptions from "~/components/FilterOptions"; + +type Props = { + value: NotificationChannelType[]; + onChange: (channels: NotificationChannelType[]) => void; + slackDisabled?: boolean; +}; + +/** + * A dropdown selector for managing notification channel preferences. + * Displays enabled channels and allows toggling via a popover menu. + */ +function ChannelSelector({ value, onChange, slackDisabled = false }: Props) { + const { t } = useTranslation(); + + const channels = React.useMemo( + () => [ + { + key: NotificationChannelType.Email, + label: t("Email"), + icon: , + }, + { + key: NotificationChannelType.Slack, + label: t("Slack"), + icon: , + }, + ], + [t, slackDisabled] + ); + + const handleToggle = React.useCallback( + (channelType: NotificationChannelType) => { + const newValue = value.includes(channelType) + ? value.filter((c) => c !== channelType) + : [...value, channelType]; + onChange(newValue); + }, + [value, onChange] + ); + + return ( + + ); +} + +export default ChannelSelector; diff --git a/patches/@chat-adapter+shared+4.15.0.patch b/patches/@chat-adapter+shared+4.15.0.patch new file mode 100644 index 0000000000..42c10125ff --- /dev/null +++ b/patches/@chat-adapter+shared+4.15.0.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/@chat-adapter/shared/package.json b/node_modules/@chat-adapter/shared/package.json +index 3015551..d624867 100644 +--- a/node_modules/@chat-adapter/shared/package.json ++++ b/node_modules/@chat-adapter/shared/package.json +@@ -6,7 +6,8 @@ + "exports": { + ".": { + "types": "./dist/index.d.ts", +- "import": "./dist/index.js" ++ "import": "./dist/index.js", ++ "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", +@@ -46,4 +47,4 @@ + "build": "tsup", + "test": "vitest run --coverage" + } +-} +\ No newline at end of file ++} \ No newline at end of file diff --git a/patches/@chat-adapter+slack+4.15.0.patch b/patches/@chat-adapter+slack+4.15.0.patch new file mode 100644 index 0000000000..ebd88aeeae --- /dev/null +++ b/patches/@chat-adapter+slack+4.15.0.patch @@ -0,0 +1,21 @@ +diff --git a/node_modules/@chat-adapter/slack/package.json b/node_modules/@chat-adapter/slack/package.json +index 871fe33..a454831 100644 +--- a/node_modules/@chat-adapter/slack/package.json ++++ b/node_modules/@chat-adapter/slack/package.json +@@ -9,7 +9,8 @@ + "exports": { + ".": { + "types": "./dist/index.d.ts", +- "import": "./dist/index.js" ++ "import": "./dist/index.js", ++ "default": "./dist/index.js" + } + }, + "files": [ +@@ -53,4 +54,4 @@ + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + } +-} +\ No newline at end of file ++} \ No newline at end of file diff --git a/patches/chat+4.15.0.patch b/patches/chat+4.15.0.patch new file mode 100644 index 0000000000..36c888eab3 --- /dev/null +++ b/patches/chat+4.15.0.patch @@ -0,0 +1,33 @@ +diff --git a/node_modules/chat/package.json b/node_modules/chat/package.json +index 89e4e44..24b552d 100644 +--- a/node_modules/chat/package.json ++++ b/node_modules/chat/package.json +@@ -9,15 +9,18 @@ + "exports": { + ".": { + "types": "./dist/index.d.ts", +- "import": "./dist/index.js" ++ "import": "./dist/index.js", ++ "default": "./dist/index.js" + }, + "./jsx-runtime": { + "types": "./dist/jsx-runtime.d.ts", +- "import": "./dist/jsx-runtime.js" ++ "import": "./dist/jsx-runtime.js", ++ "default": "./dist/jsx-runtime.js" + }, + "./jsx-dev-runtime": { + "types": "./dist/jsx-runtime.d.ts", +- "import": "./dist/jsx-runtime.js" ++ "import": "./dist/jsx-runtime.js", ++ "default": "./dist/jsx-runtime.js" + } + }, + "files": [ +@@ -68,4 +71,4 @@ + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist docs" + } +-} +\ No newline at end of file ++} \ No newline at end of file diff --git a/plugins/slack/server/auth/slack.ts b/plugins/slack/server/auth/slack.ts index b33c6cf53f..8c6336f00e 100644 --- a/plugins/slack/server/auth/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -254,17 +254,38 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { case IntegrationType.LinkedAccount: { // validation middleware ensures that code is non-null at this point const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl()); - await Integration.create>({ - service: IntegrationService.Slack, - type: IntegrationType.LinkedAccount, - userId: user.id, - teamId: user.teamId, - settings: { - slack: { - serviceUserId: data.user_id, - serviceTeamId: data.team_id, + + await sequelize.transaction(async (transaction) => { + const authentication = await IntegrationAuthentication.create( + { + service: IntegrationService.Slack, + userId: user.id, + teamId: user.teamId, + token: data.access_token, + scopes: data.scope.split(","), }, - }, + { transaction } + ); + + await Integration.create< + Integration + >( + { + service: IntegrationService.Slack, + type: IntegrationType.LinkedAccount, + userId: user.id, + teamId: user.teamId, + // need to add events + authenticationId: authentication.id, + settings: { + slack: { + serviceUserId: data.user_id, + serviceTeamId: data.team_id, + }, + }, + }, + { transaction } + ); }); break; } diff --git a/plugins/slack/server/env.ts b/plugins/slack/server/env.ts index eaccb27832..ecf7fee866 100644 --- a/plugins/slack/server/env.ts +++ b/plugins/slack/server/env.ts @@ -37,6 +37,12 @@ class SlackPluginEnvironment extends Environment { environment.SLACK_CLIENT_SECRET ?? environment.SLACK_SECRET ); + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_SIGNING_SECRET = this.toOptionalString( + environment.SLACK_SIGNING_SECRET + ); + /** * Secret to verify webhook requests received from Slack. */ diff --git a/plugins/slack/server/slack.ts b/plugins/slack/server/slack.ts index 2bb6c984a3..5c232fb1b3 100644 --- a/plugins/slack/server/slack.ts +++ b/plugins/slack/server/slack.ts @@ -3,9 +3,50 @@ import { InvalidRequestError } from "@server/errors"; import fetch from "@server/utils/fetch"; import { SlackUtils } from "../shared/SlackUtils"; import env from "./env"; +import type { AdapterPostableMessage } from "chat"; const SLACK_API_URL = "https://slack.com/api"; +/** + * send a message to a Slack channel + * + * @param token - the Slack API token to authenticate the request. + * @param channel - the channel ID or user ID to send the message to. + * @param message - the message content to send. + * + * @returns the response after posting the message. + */ +export async function postMessage({ + token, + channel, + message, +}: { + token: string; + channel: string; + message: AdapterPostableMessage; +}) { + if (!token) { + throw InvalidRequestError("Slack API token is required"); + } + + try { + const adapter = createSlackAdapter({ + botToken: token, + signingSecret: env.SLACK_SIGNING_SECRET, + }); + + const threadId = await adapter.openDM(channel); + const result = await adapter.postMessage(threadId, message); + + return result; + } catch (err) { + if (err.data) { + throw InvalidRequestError(err.data.error || err.message); + } + throw InvalidRequestError(err.message); + } +} + /** * Makes a POST request to the Slack API with JSON body. * diff --git a/server/migrations/20260211000000-add-slack-sent-at-to-notifications.js b/server/migrations/20260211000000-add-slack-sent-at-to-notifications.js new file mode 100644 index 0000000000..310965e198 --- /dev/null +++ b/server/migrations/20260211000000-add-slack-sent-at-to-notifications.js @@ -0,0 +1,26 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn( + "notifications", + "slackSentAt", + { + type: Sequelize.DATE, + allowNull: true, + }, + { transaction } + ); + }); + }, + + async down(queryInterface) { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeColumn("notifications", "slackSentAt", { + transaction, + }); + }); + }, +}; diff --git a/server/models/Notification.ts b/server/models/Notification.ts index c48105b234..1bd534a43c 100644 --- a/server/models/Notification.ts +++ b/server/models/Notification.ts @@ -117,6 +117,10 @@ class Notification extends Model< @Column emailedAt?: Date | null; + @AllowNull + @Column + slackSentAt?: Date | null; + @AllowNull @Column viewedAt: Date | null; diff --git a/server/models/User.ts b/server/models/User.ts index 8234c36658..94729efef6 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -40,6 +40,7 @@ import type { } from "@shared/types"; import { CollectionPermission, + NotificationChannelType, NotificationEventDefaults, UserRole, DocumentPermission, @@ -348,28 +349,100 @@ class User extends ParanoidModel< /** * Sets a preference for the users notification settings. * - * @param type The type of notification event - * @param value Set the preference to true/false + * @param type The type of notification event. + * @param value Set the preference to true/false or channel-specific settings. + * @param channel Optional channel type for channel-specific settings. */ public setNotificationEventType = ( type: NotificationEventType, - value = true + value: boolean | Record, + channel?: NotificationChannelType ) => { - this.notificationSettings = { - ...this.notificationSettings, - [type]: value, - }; + if (channel !== undefined) { + // Setting a specific channel preference + const currentSetting = this.notificationSettings[type]; + const channelSettings = + typeof currentSetting === "object" ? currentSetting : {}; + + this.notificationSettings = { + ...this.notificationSettings, + [type]: { + ...channelSettings, + [channel]: value, + }, + }; + } else { + // Setting all channels or simple boolean + this.notificationSettings = { + ...this.notificationSettings, + [type]: value, + }; + } }; /** * Returns the current preference for the given notification event type taking * into account the default system value. * - * @param type The type of notification event - * @returns The current preference + * @param type The type of notification event. + * @param channel Optional channel type for channel-specific check. + * @returns The current preference. */ - public subscribedToEventType = (type: NotificationEventType) => - this.notificationSettings[type] ?? NotificationEventDefaults[type] ?? false; + public subscribedToEventType = ( + type: NotificationEventType, + channel = NotificationChannelType.Email + ): boolean => { + const setting = this.notificationSettings[type]; + const defaultValue = NotificationEventDefaults[type] ?? false; + + if (setting === undefined) { + return defaultValue; + } + + if (typeof setting === "boolean") { + return setting; + } + + if (typeof setting === "object") { + return setting[channel] ?? defaultValue; + } + + return defaultValue; + }; + + /** + * Returns the user's Slack user ID if they have linked their Slack account. + * + * @returns The Slack user ID or null. + */ + public getSlackUserId = async (): Promise => { + const { Integration } = await import("./index"); + const { IntegrationType } = await import("@shared/types"); + const integration = await Integration.findOne({ + where: { + userId: this.id, + service: "slack", + type: IntegrationType.LinkedAccount, + }, + }); + + if (!integration || typeof integration.settings !== "object") { + return null; + } + + const { settings } = integration; + if ( + settings && + "slack" in settings && + settings.slack && + typeof settings.slack === "object" && + "serviceUserId" in settings.slack + ) { + return settings.slack.serviceUserId as string; + } + + return null; + }; /** * User flags are for storing information on a user record that is not visible diff --git a/server/queues/processors/SlackProcessor.ts b/server/queues/processors/SlackProcessor.ts new file mode 100644 index 0000000000..40b38040fa --- /dev/null +++ b/server/queues/processors/SlackProcessor.ts @@ -0,0 +1,183 @@ +import { + IntegrationService, + NotificationChannelType, + NotificationEventType, +} from "@shared/types"; +import { Notification, IntegrationAuthentication } from "@server/models"; +import type { Event, NotificationEvent } from "@server/types"; +import * as Slack from "../../../plugins/slack/server/slack"; +import BaseProcessor from "./BaseProcessor"; +import Logger from "@server/logging/Logger"; +import { paragraph, root, strong, text, link } from "chat"; + +/** + * Processor for sending Slack DM notifications. + * Listens for notification.create events and sends Slack DMs to users + * who have linked their Slack accounts and enabled Slack notifications. + */ +export default class SlackNotificationsProcessor extends BaseProcessor { + static applicableEvents: Event["name"][] = ["notifications.create"]; + + async perform(event: NotificationEvent) { + const notification = await Notification.scope([ + "withTeam", + "withUser", + "withActor", + ]).findByPk(event.modelId); + + if (!notification) { + return; + } + + if (notification.user.isSuspended) { + return; + } + + // Check if user has Slack notifications enabled for this event type + if ( + !notification.user.subscribedToEventType( + notification.event, + NotificationChannelType.Slack + ) + ) { + return; + } + + const slackUserId = await notification.user.getSlackUserId(); + if (!slackUserId) { + Logger.info( + "processor", + `User ${notification.userId} has no linked Slack account` + ); + return; + } + + const auth = await IntegrationAuthentication.findOne({ + where: { + service: IntegrationService.Slack, + teamId: notification.user.teamId, + }, + }); + + if (!auth) { + Logger.debug( + "plugins", + "No Slack integration authentication found for team", + { + teamId: notification.user.teamId, + } + ); + return; + } + + try { + const message = this.formatSlackMessage(notification); + + await Slack.postMessage({ + token: auth?.token, + channel: slackUserId, + message, + }); + + await notification.update({ + slackSentAt: new Date(), + }); + + Logger.info( + "processor", + `Slack DM sent for notification ${notification.id}` + ); + } catch (error) { + Logger.error( + `Failed to send Slack DM for notification ${notification.id}`, + error + ); + } + } + + /** + * Format a notification into a Slack message with rich formatting. + * + * @param notification - the notification to format. + * @returns the formatted Slack message. + */ + private formatSlackMessage(notification: Notification) { + const actorName = notification.actor.name; + const teamUrl = notification.team.url; + let textContent = ""; + let url = ""; + + switch (notification.event) { + case NotificationEventType.PublishDocument: + textContent = `${actorName} published a new document`; + url = `${teamUrl}/doc/${notification.documentId}`; + break; + + case NotificationEventType.UpdateDocument: + textContent = `${actorName} updated a document you're subscribed to`; + url = `${teamUrl}/doc/${notification.documentId}`; + break; + + case NotificationEventType.MentionedInDocument: + textContent = `${actorName} mentioned you in a document`; + url = `${teamUrl}/doc/${notification.documentId}`; + break; + + case NotificationEventType.MentionedInComment: + textContent = `${actorName} mentioned you in a comment`; + url = `${teamUrl}/doc/${notification.documentId}?commentId=${notification.commentId}`; + break; + + case NotificationEventType.GroupMentionedInDocument: + textContent = `${actorName} mentioned a group you're in`; + url = `${teamUrl}/doc/${notification.documentId}`; + break; + + case NotificationEventType.GroupMentionedInComment: + textContent = `${actorName} mentioned a group you're in`; + url = `${teamUrl}/doc/${notification.documentId}?commentId=${notification.commentId}`; + break; + + case NotificationEventType.CreateComment: + textContent = `${actorName} commented on a document you're subscribed to`; + url = `${teamUrl}/doc/${notification.documentId}?commentId=${notification.commentId}`; + break; + + case NotificationEventType.ResolveComment: + textContent = `${actorName} resolved a comment thread you participated in`; + url = `${teamUrl}/doc/${notification.documentId}?commentId=${notification.commentId}`; + break; + + case NotificationEventType.CreateCollection: + textContent = `A new collection was created`; + url = `${teamUrl}/collection/${notification.collectionId}`; + break; + + case NotificationEventType.AddUserToDocument: + textContent = `${actorName} shared a document with you`; + url = `${teamUrl}/doc/${notification.documentId}`; + break; + + case NotificationEventType.AddUserToCollection: + textContent = `${actorName} shared a collection with you`; + url = `${teamUrl}/collection/${notification.collectionId}`; + break; + + default: + textContent = `You have a new notification from ${actorName}`; + url = teamUrl; + } + + const message = { + ast: root([ + paragraph([ + strong([text(textContent)]), + text("\n"), + link(url, [text("View")]), + ]), + ]), + }; + + return message; + } +} diff --git a/server/routes/api/users/schema.ts b/server/routes/api/users/schema.ts index 4488ffd342..684c77b493 100644 --- a/server/routes/api/users/schema.ts +++ b/server/routes/api/users/schema.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { NotificationBadgeType, + NotificationChannelType, NotificationEventType, UserPreference, UserRole, diff --git a/server/routes/api/users/users.ts b/server/routes/api/users/users.ts index 238dd050c9..440c4514fb 100644 --- a/server/routes/api/users/users.ts +++ b/server/routes/api/users/users.ts @@ -681,7 +681,7 @@ router.post( validate(T.UsersNotificationsSubscribeSchema), transaction(), async (ctx: APIContext) => { - const { eventType } = ctx.input.body; + const { eventType, channel } = ctx.input.body; const { user } = ctx.state.auth; const eventTypes = eventType ? [eventType] @@ -705,7 +705,7 @@ router.post( validate(T.UsersNotificationsUnsubscribeSchema), transaction(), async (ctx: APIContext) => { - const { eventType } = ctx.input.body; + const { eventType, channel } = ctx.input.body; const { user } = ctx.state.auth; const eventTypes = eventType ? [eventType] diff --git a/yarn.lock b/yarn.lock index fa44d7fb40..6652d1b8f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6646,7 +6646,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.1.8": +"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.8": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -7041,6 +7041,15 @@ __metadata: languageName: node linkType: hard +"@types/mdast@npm:^4.0.0": + version: 4.0.4 + resolution: "@types/mdast@npm:4.0.4" + dependencies: + "@types/unist": "npm:*" + checksum: 10c0/84f403dbe582ee508fd9c7643ac781ad8597fcbfc9ccb8d4715a2c92e4545e5772cbd0dbdf18eda65789386d81b009967fdef01b24faf6640f817287f54d9c82 + languageName: node + linkType: hard + "@types/mdurl@npm:^2": version: 2.0.0 resolution: "@types/mdurl@npm:2.0.0" @@ -7093,12 +7102,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=10.0.0": - version: 25.0.1 - resolution: "@types/node@npm:25.0.1" +"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:>=18.0.0": + version: 25.0.3 + resolution: "@types/node@npm:25.0.3" dependencies: undici-types: "npm:~7.16.0" - checksum: 10c0/1d5ca9f240d0cf8e43d5281c0e6ee96fb22d37dc2e5ef52c6ca71de47957a6128e124990cedf5b14c03d0250737bd78ad370d93bcf1729a75ca4e54384fdd51a + checksum: 10c0/b7568f0d765d9469621615e2bb257c7fd1953d95e9acbdb58dffb6627a2c4150d405a4600aa1ad8a40182a94fe5f903cafd3c0a2f5132814debd0e3bfd61f835 languageName: node linkType: hard @@ -7339,6 +7348,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 10c0/7c5c9086369826f569b83a4683661557cab1361bac0897a1cefa1a915ff739acd10ca0d62b01071046fe3f5a3f7f2aec80785fe283b75602dc6726781ea3e328 + languageName: node + linkType: hard + "@types/sanitize-filename@npm:^1.6.3": version: 1.6.3 resolution: "@types/sanitize-filename@npm:1.6.3" @@ -7478,6 +7494,13 @@ __metadata: languageName: node linkType: hard +"@types/unist@npm:*, @types/unist@npm:^3.0.0": + version: 3.0.3 + resolution: "@types/unist@npm:3.0.3" + checksum: 10c0/2b1e4adcab78388e088fcc3c0ae8700f76619dbcb4741d7d201f87e2cb346bfc29a89003cfea2d76c996e1061452e14fcd737e8b25aacf949c1f2d6b2bc3dd60 + languageName: node + linkType: hard + "@types/unist@npm:^2": version: 2.0.11 resolution: "@types/unist@npm:2.0.11" @@ -7678,6 +7701,13 @@ __metadata: languageName: node linkType: hard +"@workflow/serde@npm:4.1.0-beta.2": + version: 4.1.0-beta.2 + resolution: "@workflow/serde@npm:4.1.0-beta.2" + checksum: 10c0/c84efdb22106ab77d95873022de1b645b1ac7288d85a78e74946c11b83fb22a89e301da649aa0750e1f65ff4655a5d7bf02d27b5022aff86ef4d3d8362a97fe7 + languageName: node + linkType: hard + "@xmldom/xmldom@npm:^0.8.6": version: 0.8.13 resolution: "@xmldom/xmldom@npm:0.8.13" @@ -8616,6 +8646,13 @@ __metadata: languageName: node linkType: hard +"character-entities@npm:^2.0.0": + version: 2.0.2 + resolution: "character-entities@npm:2.0.2" + checksum: 10c0/b0c645a45bcc90ff24f0e0140f4875a8436b8ef13b6bcd31ec02cfb2ca502b680362aa95386f7815bdc04b6464d48cf191210b3840d7c04241a149ede591a308 + languageName: node + linkType: hard + "character-reference-invalid@npm:^1.0.0": version: 1.1.4 resolution: "character-reference-invalid@npm:1.1.4" @@ -8623,6 +8660,21 @@ __metadata: languageName: node linkType: hard +"chat@npm:4.17.0, chat@npm:^4.15.0": + version: 4.17.0 + resolution: "chat@npm:4.17.0" + dependencies: + "@workflow/serde": "npm:4.1.0-beta.2" + mdast-util-to-string: "npm:^4.0.0" + remark-gfm: "npm:^4.0.0" + remark-parse: "npm:^11.0.0" + remark-stringify: "npm:^11.0.0" + remend: "npm:^1.2.1" + unified: "npm:^11.0.5" + checksum: 10c0/951ee54520e40711e001039147f6081751d6d52f05ebf80bee79c772edcfc2204cc14dc1a9afef2e4673e99453f93cae54879f7dfbe798d09465dbc65444cb30 + languageName: node + linkType: hard + "cheerio-select@npm:^2.1.0": version: 2.1.0 resolution: "cheerio-select@npm:2.1.0" @@ -9906,6 +9958,15 @@ __metadata: languageName: node linkType: hard +"decode-named-character-reference@npm:^1.0.0": + version: 1.2.0 + resolution: "decode-named-character-reference@npm:1.2.0" + dependencies: + character-entities: "npm:^2.0.0" + checksum: 10c0/761a89de6b0e0a2d4b21ae99074e4cc3344dd11eb29f112e23cc5909f2e9f33c5ed20cd6b146b27fb78170bce0f3f9b3362a84b75638676a05c938c24a60f5d7 + languageName: node + linkType: hard + "decode-uri-component@npm:^0.2.2": version: 0.2.2 resolution: "decode-uri-component@npm:0.2.2" @@ -10021,6 +10082,15 @@ __metadata: languageName: node linkType: hard +"devlop@npm:^1.0.0, devlop@npm:^1.1.0": + version: 1.1.0 + resolution: "devlop@npm:1.1.0" + dependencies: + dequal: "npm:^2.0.0" + checksum: 10c0/e0928ab8f94c59417a2b8389c45c55ce0a02d9ac7fd74ef62d01ba48060129e1d594501b77de01f3eeafc7cb00773819b0df74d96251cf20b31c5b3071f45c0e + languageName: node + linkType: hard + "dezalgo@npm:^1.0.4": version: 1.0.4 resolution: "dezalgo@npm:1.0.4" @@ -10755,6 +10825,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^4.0.4": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b + languageName: node + linkType: hard + "eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" @@ -12553,6 +12630,13 @@ __metadata: languageName: node linkType: hard +"is-electron@npm:2.2.2": + version: 2.2.2 + resolution: "is-electron@npm:2.2.2" + checksum: 10c0/327bb373f7be01b16cdff3998b5ddaa87d28f576092affaa7fe0659571b3306fdd458afbf0683a66841e7999af13f46ad0e1b51647b469526cd05a4dd736438a + languageName: node + linkType: hard + "is-extendable@npm:^0.1.0": version: 0.1.1 resolution: "is-extendable@npm:0.1.1" @@ -12743,7 +12827,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": +"is-stream@npm:^2, is-stream@npm:^2.0.0": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 @@ -13911,6 +13995,13 @@ __metadata: languageName: node linkType: hard +"longest-streak@npm:^3.0.0": + version: 3.1.0 + resolution: "longest-streak@npm:3.1.0" + checksum: 10c0/7c2f02d0454b52834d1bcedef79c557bd295ee71fdabb02d041ff3aa9da48a90b5df7c0409156dedbc4df9b65da18742652aaea4759d6ece01f08971af6a7eaa + languageName: node + linkType: hard + "loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.2.0, loose-envify@npm:^1.3.1, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -14111,6 +14202,151 @@ __metadata: languageName: node linkType: hard +"mdast-util-find-and-replace@npm:^3.0.0": + version: 3.0.2 + resolution: "mdast-util-find-and-replace@npm:3.0.2" + dependencies: + "@types/mdast": "npm:^4.0.0" + escape-string-regexp: "npm:^5.0.0" + unist-util-is: "npm:^6.0.0" + unist-util-visit-parents: "npm:^6.0.0" + checksum: 10c0/c8417a35605d567772ff5c1aa08363ff3010b0d60c8ea68c53cba09bf25492e3dd261560425c1756535f3b7107f62e7ff3857cdd8fb1e62d1b2cc2ea6e074ca2 + languageName: node + linkType: hard + +"mdast-util-from-markdown@npm:^2.0.0": + version: 2.0.2 + resolution: "mdast-util-from-markdown@npm:2.0.2" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + decode-named-character-reference: "npm:^1.0.0" + devlop: "npm:^1.0.0" + mdast-util-to-string: "npm:^4.0.0" + micromark: "npm:^4.0.0" + micromark-util-decode-numeric-character-reference: "npm:^2.0.0" + micromark-util-decode-string: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + micromark-util-symbol: "npm:^2.0.0" + micromark-util-types: "npm:^2.0.0" + unist-util-stringify-position: "npm:^4.0.0" + checksum: 10c0/76eb2bd2c6f7a0318087c73376b8af6d7561c1e16654e7667e640f391341096c56142618fd0ff62f6d39e5ab4895898b9789c84cd7cec2874359a437a0e1ff15 + languageName: node + linkType: hard + +"mdast-util-gfm-autolink-literal@npm:^2.0.0": + version: 2.0.1 + resolution: "mdast-util-gfm-autolink-literal@npm:2.0.1" + dependencies: + "@types/mdast": "npm:^4.0.0" + ccount: "npm:^2.0.0" + devlop: "npm:^1.0.0" + mdast-util-find-and-replace: "npm:^3.0.0" + micromark-util-character: "npm:^2.0.0" + checksum: 10c0/963cd22bd42aebdec7bdd0a527c9494d024d1ad0739c43dc040fee35bdfb5e29c22564330a7418a72b5eab51d47a6eff32bc0255ef3ccb5cebfe8970e91b81b6 + languageName: node + linkType: hard + +"mdast-util-gfm-footnote@npm:^2.0.0": + version: 2.1.0 + resolution: "mdast-util-gfm-footnote@npm:2.1.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.1.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + micromark-util-normalize-identifier: "npm:^2.0.0" + checksum: 10c0/8ab965ee6be3670d76ec0e95b2ba3101fc7444eec47564943ab483d96ac17d29da2a4e6146a2a288be30c21b48c4f3938a1e54b9a46fbdd321d49a5bc0077ed0 + languageName: node + linkType: hard + +"mdast-util-gfm-strikethrough@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-strikethrough@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/b053e93d62c7545019bd914271ea9e5667ad3b3b57d16dbf68e56fea39a7e19b4a345e781312714eb3d43fdd069ff7ee22a3ca7f6149dfa774554f19ce3ac056 + languageName: node + linkType: hard + +"mdast-util-gfm-table@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-table@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + markdown-table: "npm:^3.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/128af47c503a53bd1c79f20642561e54a510ad5e2db1e418d28fefaf1294ab839e6c838e341aef5d7e404f9170b9ca3d1d89605f234efafde93ee51174a6e31e + languageName: node + linkType: hard + +"mdast-util-gfm-task-list-item@npm:^2.0.0": + version: 2.0.0 + resolution: "mdast-util-gfm-task-list-item@npm:2.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + devlop: "npm:^1.0.0" + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/258d725288482b636c0a376c296431390c14b4f29588675297cb6580a8598ed311fc73ebc312acfca12cc8546f07a3a285a53a3b082712e2cbf5c190d677d834 + languageName: node + linkType: hard + +"mdast-util-gfm@npm:^3.0.0": + version: 3.1.0 + resolution: "mdast-util-gfm@npm:3.1.0" + dependencies: + mdast-util-from-markdown: "npm:^2.0.0" + mdast-util-gfm-autolink-literal: "npm:^2.0.0" + mdast-util-gfm-footnote: "npm:^2.0.0" + mdast-util-gfm-strikethrough: "npm:^2.0.0" + mdast-util-gfm-table: "npm:^2.0.0" + mdast-util-gfm-task-list-item: "npm:^2.0.0" + mdast-util-to-markdown: "npm:^2.0.0" + checksum: 10c0/4bedcfb6a20e39901c8772f0d2bb2d7a64ae87a54c13cbd92eec062cf470fbb68c2ad754e149af5b30794e2de61c978ab1de1ace03c0c40f443ca9b9b8044f81 + languageName: node + linkType: hard + +"mdast-util-phrasing@npm:^4.0.0": + version: 4.1.0 + resolution: "mdast-util-phrasing@npm:4.1.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/bf6c31d51349aa3d74603d5e5a312f59f3f65662ed16c58017169a5fb0f84ca98578f626c5ee9e4aa3e0a81c996db8717096705521bddb4a0185f98c12c9b42f + languageName: node + linkType: hard + +"mdast-util-to-markdown@npm:^2.0.0": + version: 2.1.2 + resolution: "mdast-util-to-markdown@npm:2.1.2" + dependencies: + "@types/mdast": "npm:^4.0.0" + "@types/unist": "npm:^3.0.0" + longest-streak: "npm:^3.0.0" + mdast-util-phrasing: "npm:^4.0.0" + mdast-util-to-string: "npm:^4.0.0" + micromark-util-classify-character: "npm:^2.0.0" + micromark-util-decode-string: "npm:^2.0.0" + unist-util-visit: "npm:^5.0.0" + zwitch: "npm:^2.0.0" + checksum: 10c0/4649722a6099f12e797bd8d6469b2b43b44e526b5182862d9c7766a3431caad2c0112929c538a972f214e63c015395e5d3f54bd81d9ac1b16e6d8baaf582f749 + languageName: node + linkType: hard + +"mdast-util-to-string@npm:^4.0.0": + version: 4.0.0 + resolution: "mdast-util-to-string@npm:4.0.0" + dependencies: + "@types/mdast": "npm:^4.0.0" + checksum: 10c0/2d3c1af29bf3fe9c20f552ee9685af308002488f3b04b12fa66652c9718f66f41a32f8362aa2d770c3ff464c034860b41715902ada2306bb0a055146cef064d7 + languageName: node + linkType: hard + "mdurl@npm:^2.0.0": version: 2.0.0 resolution: "mdurl@npm:2.0.0" @@ -15599,6 +15835,35 @@ __metadata: languageName: node linkType: hard +"p-queue@npm:^6": + version: 6.6.2 + resolution: "p-queue@npm:6.6.2" + dependencies: + eventemitter3: "npm:^4.0.4" + p-timeout: "npm:^3.2.0" + checksum: 10c0/5739ecf5806bbeadf8e463793d5e3004d08bb3f6177bd1a44a005da8fd81bb90f80e4633e1fb6f1dfd35ee663a5c0229abe26aebb36f547ad5a858347c7b0d3e + languageName: node + linkType: hard + +"p-retry@npm:^4": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": "npm:0.12.0" + retry: "npm:^0.13.1" + checksum: 10c0/d58512f120f1590cfedb4c2e0c42cb3fa66f3cea8a4646632fcb834c56055bb7a6f138aa57b20cc236fb207c9d694e362e0b5c2b14d9b062f67e8925580c73b0 + languageName: node + linkType: hard + +"p-timeout@npm:^3.2.0": + version: 3.2.0 + resolution: "p-timeout@npm:3.2.0" + dependencies: + p-finally: "npm:^1.0.0" + checksum: 10c0/524b393711a6ba8e1d48137c5924749f29c93d70b671e6db761afa784726572ca06149c715632da8f70c090073afb2af1c05730303f915604fd38ee207b70a61 + languageName: node + linkType: hard + "p-try@npm:^2.0.0": version: 2.2.0 resolution: "p-try@npm:2.2.0" @@ -19202,6 +19467,13 @@ __metadata: languageName: node linkType: hard +"trough@npm:^2.0.0": + version: 2.2.0 + resolution: "trough@npm:2.2.0" + checksum: 10c0/58b671fc970e7867a48514168894396dd94e6d9d6456aca427cc299c004fe67f35ed7172a36449086b2edde10e78a71a284ec0076809add6834fb8f857ccb9b0 + languageName: node + linkType: hard + "truncate-utf8-bytes@npm:^1.0.0": version: 1.0.2 resolution: "truncate-utf8-bytes@npm:1.0.2" @@ -19554,6 +19826,21 @@ __metadata: languageName: node linkType: hard +"unified@npm:^11.0.0, unified@npm:^11.0.5": + version: 11.0.5 + resolution: "unified@npm:11.0.5" + dependencies: + "@types/unist": "npm:^3.0.0" + bail: "npm:^2.0.0" + devlop: "npm:^1.0.0" + extend: "npm:^3.0.0" + is-plain-obj: "npm:^4.0.0" + trough: "npm:^2.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/53c8e685f56d11d9d458a43e0e74328a4d6386af51c8ac37a3dcabec74ce5026da21250590d4aff6733ccd7dc203116aae2b0769abc18cdf9639a54ae528dfc9 + languageName: node + linkType: hard + "unique-filename@npm:^5.0.0": version: 5.0.0 resolution: "unique-filename@npm:5.0.0" @@ -19821,6 +20108,26 @@ __metadata: languageName: node linkType: hard +"vfile-message@npm:^4.0.0": + version: 4.0.3 + resolution: "vfile-message@npm:4.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-stringify-position: "npm:^4.0.0" + checksum: 10c0/33d9f219610d27987689bb14fa5573d2daa146941d1a05416dd7702c4215b23f44ed81d059e70d0e4e24f9a57d5f4dc9f18d35a993f04cf9446a7abe6d72d0c0 + languageName: node + linkType: hard + +"vfile@npm:^6.0.0": + version: 6.0.3 + resolution: "vfile@npm:6.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile-message: "npm:^4.0.0" + checksum: 10c0/e5d9eb4810623f23758cfc2205323e33552fb5972e5c2e6587babe08fe4d24859866277404fb9e2a20afb71013860d96ec806cb257536ae463c87d70022ab9ef + languageName: node + linkType: hard + "vinyl-contents@npm:^2.0.0": version: 2.0.0 resolution: "vinyl-contents@npm:2.0.0" @@ -20908,3 +21215,10 @@ __metadata: checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 languageName: node linkType: hard + +"zwitch@npm:^2.0.0": + version: 2.0.4 + resolution: "zwitch@npm:2.0.4" + checksum: 10c0/3c7830cdd3378667e058ffdb4cf2bb78ac5711214e2725900873accb23f3dfe5f9e7e5a06dcdc5f29605da976fc45c26d9a13ca334d6eea2245a15e77b8fc06e + languageName: node + linkType: hard