diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index 268137392..606df44c7 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -9,7 +9,7 @@ import type { Monitor } from "@/Types/Monitor"; interface ApiResponse { success: boolean; msg: string; - data: Monitor[]; + data: Monitor[] | number; } interface UseBulkMonitorActionsReturn { @@ -17,6 +17,8 @@ interface UseBulkMonitorActionsReturn { setSelectedRows: (rows: string[]) => void; handleBulkPause: () => Promise; handleBulkResume: () => Promise; + handleBulkAddNotifications: (notificationIds: string[]) => Promise; + handleBulkRemoveNotifications: (notificationIds: string[]) => Promise; handleCancelSelection: () => void; } @@ -40,7 +42,7 @@ export const useBulkMonitorActions = ( pause, }); - const affectedCount = res.data?.data?.length ?? 0; + const affectedCount = Array.isArray(res.data?.data) ? res.data.data.length : 0; if (affectedCount === 0) { const key = pause @@ -84,11 +86,59 @@ export const useBulkMonitorActions = ( setSelectedRows([]); }; + const handleBulkNotifications = (action: "add" | "remove") => { + return async (notificationIds: string[]) => { + try { + const res = await post("/monitors/notifications", { + monitorIds: selectedRows, + notificationIds, + action, + }); + + const affectedCount = typeof res.data?.data === "number" ? res.data.data : 0; + + if (affectedCount === 0) { + toastInfo( + t("pages.common.monitors.bulkNotifications.noChange", { + count: selectedRows.length, + }) + ); + } else { + const key = + action === "add" + ? "pages.common.monitors.bulkNotifications.added" + : "pages.common.monitors.bulkNotifications.removed"; + toastSuccess(t(key, { count: affectedCount })); + } + + setSelectedRows([]); + refetch(); + } catch (err: unknown) { + let errMsg = "An error occurred"; + + if (axios.isAxiosError(err)) { + errMsg = err.response?.data?.msg || err.message || errMsg; + } else if (err instanceof Error) { + errMsg = err.message; + } + + logger.error( + "Bulk notification update failed", + err instanceof Error ? err : undefined, + { action } + ); + toastError(errMsg); + } + }; + }; + return { selectedRows, setSelectedRows, handleBulkPause, handleBulkResume, + handleBulkAddNotifications: handleBulkNotifications("add"), + handleBulkRemoveNotifications: handleBulkNotifications("remove"), handleCancelSelection, }; }; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 990768b96..6e7ed580d 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -6,13 +6,16 @@ import type { Monitor, MonitorType } from "@/Types/Monitor"; interface UseMonitorFormOptions { data?: Monitor | null; defaultType?: MonitorType; + defaultNotifications?: string[]; } -const getBaseDefaults = (data?: Monitor | null) => ({ +const getBaseDefaults = (data?: Monitor | null, defaultNotifications?: string[]) => ({ name: data?.name || "", description: data?.description || "", interval: data?.interval || 60000, - notifications: data?.notifications || [], + notifications: data?.notifications?.length + ? data.notifications + : (defaultNotifications ?? []), statusWindowSize: data?.statusWindowSize || 5, statusWindowThreshold: data?.statusWindowThreshold || 60, geoCheckEnabled: data?.geoCheckEnabled ?? false, @@ -23,10 +26,11 @@ const getBaseDefaults = (data?: Monitor | null) => ({ export const useMonitorForm = ({ data = null, defaultType = "http", + defaultNotifications, }: UseMonitorFormOptions = {}) => { return useMemo(() => { const type = data?.type || defaultType; - const base = getBaseDefaults(data); + const base = getBaseDefaults(data, defaultNotifications); let defaults: MonitorFormData; @@ -136,5 +140,5 @@ export const useMonitorForm = ({ } return { schema: monitorSchema, defaults }; - }, [data, defaultType]); + }, [data, defaultType, defaultNotifications]); }; diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index d08f17548..d84d21ffc 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -8,6 +8,7 @@ interface UseNotificationFormOptions { } function buildDefaults(data: Notification | null): NotificationFormData { + const isDefault = data?.isDefault ?? false; if (data?.type === "matrix") { return { type: "matrix", @@ -15,6 +16,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { homeserverUrl: data.homeserverUrl || "", roomId: data.roomId || "", accessToken: data.accessToken || "", + isDefault, }; } if (data?.type === "telegram") { @@ -23,6 +25,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", + isDefault, }; } if (data?.type === "slack") { @@ -30,6 +33,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "slack", notificationName: data.notificationName || "", address: data.address || "", + isDefault, }; } if (data?.type === "discord") { @@ -37,6 +41,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "discord", notificationName: data.notificationName || "", address: data.address || "", + isDefault, }; } if (data?.type === "webhook") { @@ -44,6 +49,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "webhook", notificationName: data.notificationName || "", address: data.address || "", + isDefault, }; } if (data?.type === "pager_duty") { @@ -51,6 +57,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "pager_duty", notificationName: data.notificationName || "", address: data.address || "", + isDefault, }; } if (data?.type === "teams") { @@ -58,6 +65,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { type: "teams", notificationName: data.notificationName || "", address: data.address || "", + isDefault, }; } if (data?.type === "twilio") { @@ -68,6 +76,7 @@ function buildDefaults(data: Notification | null): NotificationFormData { accessToken: data.accessToken || "", phone: data.phone || "", twilioPhoneNumber: data.twilioPhoneNumber || "", + isDefault, }; } if (data?.type === "pushover") { @@ -76,13 +85,14 @@ function buildDefaults(data: Notification | null): NotificationFormData { notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", + isDefault, }; } - // Default: email (covers both data === null and data.type === "email") return { type: "email", notificationName: data?.notificationName || "", address: data?.address || "", + isDefault, }; } diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 235ac95bf..c9517e704 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -236,11 +236,16 @@ const CreateMonitorPage = () => { ); const { data: notifications } = useGet("/notifications/team"); + const { data: defaultNotifications } = useGet( + "/notifications/defaults" + ); + const defaultNotificationIds = defaultNotifications?.map((n) => n.id) ?? []; const { data: games } = useGet("/monitors/games"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, defaultType, + defaultNotifications: defaultNotificationIds, }); const form = useForm({ diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 63031f41e..ac216b905 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -7,21 +7,24 @@ import { HeaderMonitorsSummary, BulkActionsBar, } from "@/Components/monitors"; -import { TextField, Dialog, Button } from "@/Components/inputs"; -import { Play, Pause } from "lucide-react"; +import { TextField, Dialog, Button, Autocomplete } from "@/Components/inputs"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; +import { Play, Pause, Bell } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useGet, useDelete } from "@/Hooks/UseApi"; -import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor"; -import { useTheme } from "@mui/material"; -import { useState, useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; +import { useState, useMemo, useCallback } from "react"; import { useSelector, useDispatch } from "react-redux"; import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { RootState } from "@/Types/state"; -import { InfraMonitorsTable } from "./Components/MonitorsTable"; +import { useTheme } from "@mui/material"; import useDebounce from "@/Hooks/useDebounce"; import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; +import type { Notification } from "@/Types/Notification"; const InfrastructureMonitors = () => { const { t } = useTranslation(); @@ -43,6 +46,9 @@ const InfrastructureMonitors = () => { const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); + const [notificationDialogOpen, setNotificationDialogOpen] = useState(false); + const [selectedNotificationIds, setSelectedNotificationIds] = useState([]); + const [notificationAction, setNotificationAction] = useState<"add" | "remove">("add"); const isDialogOpen = Boolean(selectedMonitor); @@ -104,12 +110,16 @@ const InfrastructureMonitors = () => { const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; const isLoading = monitorsWithChecksLoading; + const { data: notifications } = useGet("/notifications/team"); + // Bulk actions const { selectedRows, setSelectedRows, handleBulkPause, handleBulkResume, + handleBulkAddNotifications, + handleBulkRemoveNotifications, handleCancelSelection, } = useBulkMonitorActions(refetch, page); @@ -136,6 +146,28 @@ const InfrastructureMonitors = () => { setSelectedMonitor(null); }; + const openAddNotifications = () => { + setNotificationAction("add"); + setSelectedNotificationIds([]); + setNotificationDialogOpen(true); + }; + + const openRemoveNotifications = () => { + setNotificationAction("remove"); + setSelectedNotificationIds([]); + setNotificationDialogOpen(true); + }; + + const handleNotificationConfirm = async () => { + if (notificationAction === "add") { + await handleBulkAddNotifications(selectedNotificationIds); + } else { + await handleBulkRemoveNotifications(selectedNotificationIds); + } + setNotificationDialogOpen(false); + setSelectedNotificationIds([]); + }; + return ( { > {t("common.buttons.pause")} + + )} @@ -230,6 +277,42 @@ const InfrastructureMonitors = () => { onCancel={handleCancel} loading={isDeleting} /> + setNotificationDialogOpen(false)} + confirmText={t("common.buttons.confirm")} + > + + + {notificationAction === "add" + ? t("pages.uptime.monitors.bulkActions.selectToAdd") + : t("pages.uptime.monitors.bulkActions.selectToRemove")} + + option.notificationName} + value={(notifications ?? []).filter((n) => + selectedNotificationIds.includes(n.id) + )} + onChange={(_, newValue) => + setSelectedNotificationIds(newValue.map((n: Notification) => n.id)) + } + renderInput={(params) => ( + + )} + /> + + ); }; diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index a7a8170f6..b32fad873 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -1,6 +1,7 @@ import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import Chip from "@mui/material/Chip"; import type { Header } from "@/Components/design-elements/Table"; import { Table } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; @@ -57,7 +58,22 @@ export const NotificationsTable = ({ id: "name", content: t("common.table.headers.name"), render: (row) => { - return {row?.notificationName}; + return ( + + {row?.notificationName} + {row?.isDefault && ( + + )} + + ); }, }, diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b652..7e01165fd 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -3,6 +3,8 @@ import { TextField, Select, Button } from "@/Components/inputs"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Stack from "@mui/material/Stack"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Switch from "@mui/material/Switch"; import { useTheme } from "@mui/material/styles"; import { useEffect, useMemo } from "react"; @@ -31,6 +33,10 @@ const NotificationsCreatePage = () => { const { post, loading: isSubmitting } = usePost(); const { patch, loading: isPatching } = usePatch(); const { post: testPost, loading: isTesting } = usePost(); + const { post: applyPost, loading: isApplying } = usePost< + Record, + { modifiedCount: number } + >(); const { schema, defaults } = useNotificationForm({ data: existingNotification }); @@ -92,6 +98,11 @@ const NotificationsCreatePage = () => { await testPost("/notifications/test", data); }; + const handleApplyToAll = async () => { + if (!notificationId) return; + await applyPost(`/notifications/${notificationId}/apply-to-all`, {}); + }; + return ( { /> } /> + ( + + } + label="" + /> + )} + /> + } + /> { justifyContent="flex-end" spacing={theme.spacing(2)} > + {isEditMode && ( + + )} + + )} @@ -248,6 +295,42 @@ const UptimeMonitorsPage = () => { onCancel={handleCancel} loading={isDeleting} /> + setNotificationDialogOpen(false)} + confirmText={t("common.buttons.confirm")} + > + + + {notificationAction === "add" + ? t("pages.uptime.monitors.bulkActions.selectToAdd") + : t("pages.uptime.monitors.bulkActions.selectToRemove")} + + option.notificationName} + value={(notifications ?? []).filter((n) => + selectedNotificationIds.includes(n.id) + )} + onChange={(_, newValue) => + setSelectedNotificationIds(newValue.map((n: Notification) => n.id)) + } + renderInput={(params) => ( + + )} + /> + + ); }; diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 9d5b914c0..75217e578 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -25,6 +25,7 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + isDefault?: boolean; createdAt: string; updatedAt: string; } diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 9fa2b011a..811fb86de 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -5,6 +5,7 @@ const baseSchema = z.object({ .string() .min(1, "Notification name is required") .max(100, "Notification name must be at most 100 characters"), + isDefault: z.boolean().optional(), }); const emailSchema = baseSchema.extend({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b964eec84..3ddbe427a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -441,42 +441,15 @@ "selectMonitor": "Select {{name}}" }, "bulkPause": { - "alreadyPaused_one": "The selected monitor is already paused", - "alreadyPaused_other": "All selected monitors are already paused", - "alreadyRunning_one": "The selected monitor is already running", - "alreadyRunning_other": "All selected monitors are already running", - "paused_one": "{{count}} monitor paused", - "paused_other": "{{count}} monitors paused", - "resumed_one": "{{count}} monitor resumed", - "resumed_other": "{{count}} monitors resumed" - }, - "statBoxes": { - "activeFor": "Active for", - "certificateExpiry": "Certificate expiry", - "lastCheck": "Last check", - "lastResponseTime": "Last response time", - "serviceIsDown": "Service is down" - }, - "status": { - "down": "down", - "breached": "threshold exceeded", - "initializing": "initializing", - "maintenance": "maintenance", - "paused": "paused", - "total": "total", - "up": "up" - }, - "monitorTypes": { - "optionDocker": "Docker", - "optionGame": "Game", - "optionHttp": "HTTP(S)", - "optionPing": "Ping", - "optionPort": "Port", - "optionPagespeed": "PageSpeed", - "optionHardware": "Infrastructure", - "optionGrpc": "gRPC", - "optionWebSocket": "WebSocket", - "optionDns": "DNS" + "paused": "Paused {count} monitor(s)", + "resumed": "Resumed {count} monitor(s)", + "alreadyPaused": "{count} monitor(s) already paused", + "alreadyRunning": "{count} monitor(s) already running" + }, + "bulkNotifications": { + "added": "Notification added to {count} monitor(s)", + "removed": "Notification removed from {count} monitor(s)", + "noChange": "No changes made to {count} monitor(s)" } } }, @@ -1094,6 +1067,13 @@ "placeholderFromNumber": "+15551234567", "optionToNumber": "To number (recipient)", "placeholderToNumber": "+15559876543" + }, + "isDefault": { + "title": "Default channel", + "description": "When enabled, this channel will be automatically attached to every new monitor you create." + }, + "applyToAll": { + "button": "Apply to all monitors" } }, "table": { @@ -1538,6 +1518,15 @@ "header": { "title": "Uptime monitors", "description": "Watch HTTP endpoints, pings, containers, and ports — and get alerted the moment something goes down." + }, + "monitors": { + "bulkActions": { + "addNotifications": "Add notification", + "removeNotifications": "Remove notification", + "selectToAdd": "Select the notification channels to add to the selected monitors:", + "selectToRemove": "Select the notification channels to remove from the selected monitors:", + "selectPlaceholder": "Select channels..." + } } } } diff --git a/server/openapi.json b/server/openapi.json index 26c58fa29..cbe0bd8f6 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -9266,6 +9266,330 @@ } } }, + "/notifications/defaults": { + "get": { + "tags": ["notifications"], + "summary": "Get default notification channels", + "description": "Retrieve all notification channels marked as default for the team. These are automatically attached to new monitors.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationChannel" + } + } + }, + "required": ["success", "msg"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/{id}/default": { + "patch": { + "tags": ["notifications"], + "summary": "Set or remove default status", + "description": "Mark a notification channel as default or remove the default status. Default notifications are automatically attached to new monitors.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "isDefault": { + "type": "boolean", + "description": "Set to true to mark as default, false to remove" + } + }, + "required": ["isDefault"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "number", + "description": "Number of notifications updated (0 or 1)" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK", + "data": 1 + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/notifications/{id}/apply-to-all": { + "post": { + "tags": ["notifications"], + "summary": "Apply notification to all monitors", + "description": "Add a notification channel to all monitors in the team. Useful for adding a new notification channel to existing infrastructure.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "number", + "description": "Number of monitors updated" + } + }, + "required": ["success", "msg"] + }, + "example": { + "success": true, + "msg": "OK", + "data": 15 + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/queue/jobs": { "get": { "tags": ["queue"], diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 309f55242..4cd6d21b1 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -350,6 +350,7 @@ export const initializeServices = async ({ monitorStatsRepository, statusPagesRepository, incidentsRepository, + notificationsRepository, }); const statusPageService = new StatusPageService(statusPagesRepository, settingsService); diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 301c15446..7ac449ffe 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -7,6 +7,8 @@ import { testNotificationBodyValidation, editNotificationParamValidation, testAllNotificationsBodyValidation, + setDefaultNotificationBodyValidation, + applyToAllBodyValidation, } from "@/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; import { INotificationsService } from "@/service/index.js"; @@ -19,10 +21,13 @@ export interface INotificationController { testNotification: (req: Request, res: Response, next: NextFunction) => Promise; createNotification: (req: Request, res: Response, next: NextFunction) => Promise; getNotificationsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; + getDefaultNotifications: (req: Request, res: Response, next: NextFunction) => Promise; deleteNotification: (req: Request, res: Response, next: NextFunction) => Promise; getNotificationById: (req: Request, res: Response, next: NextFunction) => Promise; editNotification: (req: Request, res: Response, next: NextFunction) => Promise; testAllNotifications: (req: Request, res: Response, next: NextFunction) => Promise; + setDefaultNotification: (req: Request, res: Response, next: NextFunction) => Promise; + applyToAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise; } class NotificationController implements INotificationController { private notificationsService: INotificationsService; @@ -80,6 +85,21 @@ class NotificationController implements INotificationController { } }; + getDefaultNotifications = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const defaults = await this.notificationsService.findDefaultsByTeamId(teamId); + + return res.status(200).json({ + success: true, + msg: "Default notifications fetched successfully", + data: defaults, + }); + } catch (error) { + next(error); + } + }; + deleteNotification = async (req: Request, res: Response, next: NextFunction) => { try { const teamId = requireTeamId(req.user?.teamId); @@ -158,6 +178,43 @@ class NotificationController implements INotificationController { next(error); } }; + + setDefaultNotification = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = setDefaultNotificationBodyValidation.parse(req.body); + const validatedParams = editNotificationParamValidation.parse(req.params); + + const teamId = requireTeamId(req.user?.teamId); + const notificationId = validatedParams.id; + + const modifiedCount = await this.notificationsService.setDefault(notificationId, teamId, validatedBody.isDefault); + return res.status(200).json({ + success: true, + msg: `Default notification ${validatedBody.isDefault ? "set" : "removed"}`, + data: { modifiedCount }, + }); + } catch (error) { + next(error); + } + }; + + applyToAllMonitors = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedParams = editNotificationParamValidation.parse(req.params); + + const teamId = requireTeamId(req.user?.teamId); + const notificationId = validatedParams.id; + + const modifiedCount = await this.notificationsService.applyToAllMonitors(notificationId, teamId); + return res.status(200).json({ + success: true, + msg: `Notification applied to ${modifiedCount} monitor(s)`, + data: { modifiedCount }, + }); + } catch (error) { + next(error); + } + }; } export default NotificationController; diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 2f7effa17..8f3d62fbb 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -5,6 +5,7 @@ interface NotificationDocument extends Omit( accessToken: { type: String }, accountSid: { type: String }, twilioPhoneNumber: { type: String }, + isDefault: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index ac4c8f9eb..b72d1cfd3 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -52,6 +52,7 @@ export interface IMonitorsRepository { findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise; + addNotificationToAllMonitors(teamId: string, notificationId: string): Promise; deleteByTeamIdsNotIn(teamIds: string[]): Promise; findAllMonitorIds(): Promise; } diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 3ad27b227..0c351dad5 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -544,6 +544,14 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; }; + addNotificationToAllMonitors = async (teamId: string, notificationId: string): Promise => { + const result = await MonitorModel.updateMany( + { teamId: new mongoose.Types.ObjectId(teamId) }, + { $addToSet: { notifications: new mongoose.Types.ObjectId(notificationId) } } + ); + return result.modifiedCount; + }; + deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id)); const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } }); diff --git a/server/src/repositories/notifications/INotificationsRepository.ts b/server/src/repositories/notifications/INotificationsRepository.ts index 0236c3a89..92f618446 100644 --- a/server/src/repositories/notifications/INotificationsRepository.ts +++ b/server/src/repositories/notifications/INotificationsRepository.ts @@ -8,6 +8,8 @@ export interface INotificationsRepository { findByTeamId(teamId: string): Promise; // update updateById(id: string, teamId: string, updateData: Partial): Promise; + setDefault(id: string, teamId: string, isDefault: boolean): Promise; + findDefaultsByTeamId(teamId: string): Promise; // delete deleteById(id: string, teamId: string): Promise; } diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 5303aa6ab..12dfbf397 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository { accessToken: doc.accessToken ?? undefined, accountSid: doc.accountSid ?? undefined, twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, + isDefault: doc.isDefault ?? false, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; @@ -94,6 +95,25 @@ class MongoNotificationsRepository implements INotificationsRepository { } return this.toEntity(deleted); }; + + setDefault = async (id: string, teamId: string, isDefault: boolean): Promise => { + if (isDefault) { + await NotificationModel.updateMany({ teamId: new mongoose.Types.ObjectId(teamId) }, { $set: { isDefault: false } }); + } + const result = await NotificationModel.updateOne( + { _id: new mongoose.Types.ObjectId(id), teamId: new mongoose.Types.ObjectId(teamId) }, + { $set: { isDefault } } + ); + return result.modifiedCount; + }; + + findDefaultsByTeamId = async (teamId: string): Promise => { + const documents = await NotificationModel.find({ + teamId: new mongoose.Types.ObjectId(teamId), + isDefault: true, + }); + return this.mapDocuments(documents); + }; } export default MongoNotificationsRepository; diff --git a/server/src/routes/notificationRoute.ts b/server/src/routes/notificationRoute.ts index 4ec6ce3b6..2c8fb3c78 100755 --- a/server/src/routes/notificationRoute.ts +++ b/server/src/routes/notificationRoute.ts @@ -17,10 +17,13 @@ class NotificationRoutes { this.router.post("/test", this.notificationController.testNotification); this.router.get("/team", this.notificationController.getNotificationsByTeamId); + this.router.get("/defaults", this.notificationController.getDefaultNotifications); this.router.get("/:id", this.notificationController.getNotificationById); this.router.delete("/:id", this.notificationController.deleteNotification); this.router.patch("/:id", this.notificationController.editNotification); + this.router.patch("/:id/default", this.notificationController.setDefaultNotification); + this.router.post("/:id/apply-to-all", this.notificationController.applyToAllMonitors); } getRouter() { diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 72c8f1fe8..6f145863e 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -19,6 +19,7 @@ import type { IMonitorsRepository, IMonitorStatsRepository, IStatusPagesRepository, + INotificationsRepository, } from "@/repositories/index.js"; import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" }; import { AppError } from "@/utils/AppError.js"; @@ -103,6 +104,7 @@ export class MonitorService implements IMonitorService { private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; private incidentsRepository: IIncidentsRepository; + private notificationsRepository: INotificationsRepository; constructor({ jobQueue, @@ -114,6 +116,7 @@ export class MonitorService implements IMonitorService { monitorStatsRepository, statusPagesRepository, incidentsRepository, + notificationsRepository, }: { jobQueue: ISuperSimpleQueue; logger: ILogger; @@ -124,6 +127,7 @@ export class MonitorService implements IMonitorService { monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; + notificationsRepository: INotificationsRepository; }) { this.jobQueue = jobQueue; this.logger = logger; @@ -134,6 +138,7 @@ export class MonitorService implements IMonitorService { this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; this.incidentsRepository = incidentsRepository; + this.notificationsRepository = notificationsRepository; } get serviceName(): string { @@ -166,7 +171,14 @@ export class MonitorService implements IMonitorService { }; createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { - const monitor = await this.monitorsRepository.create(body, teamId, userId); + let notifications = body.notifications ?? []; + if (notifications.length === 0) { + const defaults = await this.notificationsRepository.findDefaultsByTeamId(teamId); + if (defaults.length > 0) { + notifications = defaults.map((n) => n.id); + } + } + const monitor = await this.monitorsRepository.create({ ...body, notifications }, teamId, userId); if (!monitor) { throw new AppError({ message: "Failed to create monitor", status: 500, service: SERVICE_NAME, method: "createMonitor" }); } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 90e9d637a..9a35da234 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -11,8 +11,11 @@ export interface INotificationsService { createNotification: (notificationData: Partial, userId: string, teamId: string) => Promise; findById: (id: string, teamId: string) => Promise; findNotificationsByTeamId: (teamId: string) => Promise; + findDefaultsByTeamId: (teamId: string) => Promise; updateById(id: string, teamId: string, updateData: Partial): Promise; + setDefault(id: string, teamId: string, isDefault: boolean): Promise; deleteById: (id: string, teamId: string) => Promise; + applyToAllMonitors: (notificationId: string, teamId: string) => Promise; handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise; sendTestNotification: (notification: Partial) => Promise; @@ -218,4 +221,16 @@ export class NotificationsService implements INotificationsService { const deleted = await this.notificationsRepository.deleteById(id, teamId); return deleted; }; + + setDefault = async (id: string, teamId: string, isDefault: boolean): Promise => { + return await this.notificationsRepository.setDefault(id, teamId, isDefault); + }; + + findDefaultsByTeamId = async (teamId: string): Promise => { + return await this.notificationsRepository.findDefaultsByTeamId(teamId); + }; + + applyToAllMonitors = async (notificationId: string, teamId: string): Promise => { + return await this.monitorsRepository.addNotificationToAllMonitors(teamId, notificationId); + }; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 9d5b914c0..75217e578 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -25,6 +25,7 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + isDefault?: boolean; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 523cb2a6e..87fa7a523 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -13,6 +13,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + isDefault: z.boolean().optional(), }), // Webhook notification z.object({ @@ -22,6 +23,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + isDefault: z.boolean().optional(), }), // Slack notification z.object({ @@ -31,6 +33,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + isDefault: z.boolean().optional(), }), // Discord notification z.object({ @@ -40,6 +43,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + isDefault: z.boolean().optional(), }), // PagerDuty notification z.object({ @@ -49,6 +53,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), + isDefault: z.boolean().optional(), }), // Matrix notification z.object({ @@ -58,12 +63,14 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), roomId: z.string().min(1, "Room ID is required"), accessToken: z.string().min(1, "Access Token is required"), + isDefault: z.boolean().optional(), }), // Teams notification z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("teams"), address: z.url({ message: "Please enter a valid Webhook URL" }), + isDefault: z.boolean().optional(), }), // Telegram notification z.object({ @@ -71,6 +78,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ type: z.literal("telegram"), address: z.string().min(1, "Chat ID is required"), accessToken: z.string().min(1, "Bot token is required"), + isDefault: z.boolean().optional(), }), // Pushover notification z.object({ @@ -78,6 +86,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ type: z.literal("pushover"), address: z.string().min(1, "User key is required"), accessToken: z.string().min(1, "App token is required"), + isDefault: z.boolean().optional(), }), // Twilio SMS notification z.object({ @@ -87,6 +96,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ accessToken: z.string().min(1, "Auth token is required"), phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), + isDefault: z.boolean().optional(), }), ]); @@ -139,3 +149,9 @@ export const updateNotificationsValidation = z path: ["notificationIds"], } ); + +export const setDefaultNotificationBodyValidation = z.object({ + isDefault: z.boolean(), +}); + +export const applyToAllBodyValidation = z.object({});