From 6dbed1dfb73504b6d224232baaf39173b2fb0499 Mon Sep 17 00:00:00 2001 From: "jxt.david" Date: Fri, 15 May 2026 22:20:13 +0100 Subject: [PATCH 1/2] feat: add default notification channels and bulk notification management - Add isDefault field to notification model and types - Add endpoints: GET /notifications/defaults, PATCH /notifications/:id/default, POST /notifications/:id/apply-to-all - Add bulk add/remove notifications to monitors (PATCH /monitors/notifications) - Add UI for marking notifications as default - Add bulk notification actions in Uptime and Infrastructure monitor pages - Add Apply to All button in notification creation form - Auto-attach default notifications to new monitors - Update OpenAPI spec with new endpoints - Fix duplicate imports and JSON syntax in en.json --- client/src/Hooks/useBulkMonitorActions.ts | 52 ++- client/src/Hooks/useMonitorForm.ts | 12 +- client/src/Hooks/useNotificationForm.ts | 12 +- client/src/Pages/CreateMonitor/index.tsx | 5 + .../Pages/Infrastructure/Monitors/index.tsx | 99 +++++- .../components/NotificationsTable.tsx | 18 +- .../src/Pages/Notifications/create/index.tsx | 44 +++ client/src/Pages/Uptime/Monitors/index.tsx | 87 ++++- client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 1 + client/src/locales/en.json | 61 ++-- server/openapi.json | 324 ++++++++++++++++++ server/src/config/services.ts | 1 + .../src/controllers/notificationController.ts | 57 +++ server/src/db/models/Notification.ts | 5 + .../monitors/IMonitorsRepository.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 8 + .../notifications/INotificationsRepository.ts | 2 + .../MongoNotificationsRepository.ts | 20 ++ server/src/routes/notificationRoute.ts | 3 + server/src/service/business/monitorService.ts | 14 +- .../infrastructure/notificationsService.ts | 15 + server/src/types/notification.ts | 1 + .../src/validation/notificationValidation.ts | 6 + 24 files changed, 795 insertions(+), 54 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index 2681373920..c1ec3ec5b6 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; } @@ -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 990768b968..6e7ed580de 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 d08f175481..d84d21ffcd 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 235ac95bf1..c9517e7049 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 63031f41e0..ca619d4d29 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 { MonitorsTable } 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")} + + )} - { 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) => n.id)) + } + renderInput={(params) => ( + + )} + /> + + ); }; diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index a7a8170f66..b32fad873a 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 0f6d9b6525..a76febb7ba 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< + void, + { 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) => n.id)) + } + renderInput={(params) => ( + + )} + /> + + ); }; diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 9d5b914c02..75217e578c 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 9fa2b011ac..811fb86de7 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 b964eec842..3ddbe427a7 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 26c58fa29b..cbe0bd8f6b 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 309f552422..4cd6d21b18 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 301c15446b..7ac449ffed 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 2f7effa17d..8f3d62fbb6 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 ac4c8f9eb9..b72d1cfd3e 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 3ad27b2274..0c351dad5d 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 0236c3a89e..92f618446d 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 5303aa6ab2..12dfbf397f 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 4ec6ce3b6b..2c8fb3c78e 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 72c8f1fe86..6f145863e7 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 90e9d637ab..9a35da2347 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 9d5b914c02..75217e578c 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 523cb2a6e1..12f0e343ab 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -139,3 +139,9 @@ export const updateNotificationsValidation = z path: ["notificationIds"], } ); + +export const setDefaultNotificationBodyValidation = z.object({ + isDefault: z.boolean(), +}); + +export const applyToAllBodyValidation = z.object({}); From 8fe07b5c1522f3fbadc883bf730d3b509062daa1 Mon Sep 17 00:00:00 2001 From: "jxt.david" Date: Sat, 16 May 2026 01:17:51 +0100 Subject: [PATCH 2/2] fix: allow isDefault field in notification validation schema The createNotificationBodyValidation schema was stripping out the isDefault field when updating notifications, preventing the Default Channel feature from working properly. Changes: - Add isDefault: z.boolean().optional() to all 10 notification type schemas (email, webhook, slack, discord, pager_duty, matrix, teams, telegram, pushover, twilio) - Fix TypeScript type errors in frontend components - Fix import name in Infrastructure/Monitors page This enables users to toggle 'Default channel' on notifications and have them auto-attached to new monitors. --- client/src/Hooks/useBulkMonitorActions.ts | 2 +- client/src/Pages/Infrastructure/Monitors/index.tsx | 6 +++--- client/src/Pages/Notifications/create/index.tsx | 2 +- client/src/Pages/Uptime/Monitors/index.tsx | 2 +- server/src/validation/notificationValidation.ts | 10 ++++++++++ 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/client/src/Hooks/useBulkMonitorActions.ts b/client/src/Hooks/useBulkMonitorActions.ts index c1ec3ec5b6..606df44c77 100644 --- a/client/src/Hooks/useBulkMonitorActions.ts +++ b/client/src/Hooks/useBulkMonitorActions.ts @@ -42,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 diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index ca619d4d29..ac216b9054 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -10,7 +10,7 @@ import { import { TextField, Dialog, Button, Autocomplete } from "@/Components/inputs"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { MonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; +import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; import { Play, Pause, Bell } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -245,7 +245,7 @@ const InfrastructureMonitors = () => { )} - { selectedNotificationIds.includes(n.id) )} onChange={(_, newValue) => - setSelectedNotificationIds(newValue.map((n) => n.id)) + setSelectedNotificationIds(newValue.map((n: Notification) => n.id)) } renderInput={(params) => ( { const { patch, loading: isPatching } = usePatch(); const { post: testPost, loading: isTesting } = usePost(); const { post: applyPost, loading: isApplying } = usePost< - void, + Record, { modifiedCount: number } >(); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 03e6fddf47..5e82816688 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -320,7 +320,7 @@ const UptimeMonitorsPage = () => { selectedNotificationIds.includes(n.id) )} onChange={(_, newValue) => - setSelectedNotificationIds(newValue.map((n) => n.id)) + setSelectedNotificationIds(newValue.map((n: Notification) => n.id)) } renderInput={(params) => (