From 43ff7ca4bd4332cd811b149e955107f3e763da30 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:14:31 -0700 Subject: [PATCH 01/12] create new tag type, update related types --- server/src/types/index.ts | 1 + server/src/types/monitor.ts | 1 + server/src/types/tag.ts | 8 ++++++++ 3 files changed, 10 insertions(+) create mode 100644 server/src/types/tag.ts diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 89de348e2..ca055ba70 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -15,3 +15,4 @@ export * from "@/types/email.js"; export * from "@/types/team.js"; export * from "@/types/maintenanceWindow.js"; export * from "@/types/dlqItem.js"; +export * from "@/types/tag.js"; diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index b2ad0f76a..0708297f3 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -59,6 +59,7 @@ export interface Monitor { interval: number; uptimePercentage?: number; notifications: string[]; + tags: string[]; secret?: string; cpuAlertThreshold: number; cpuAlertCounter: number; diff --git a/server/src/types/tag.ts b/server/src/types/tag.ts new file mode 100644 index 000000000..bab6f740a --- /dev/null +++ b/server/src/types/tag.ts @@ -0,0 +1,8 @@ +export interface Tag { + id: string; + teamId: string; + name: string; + color: string; + createdAt: string; + updatedAt: string; +} From a740a9e00552e329059c894bc5fa2be0207bc02d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:14:55 -0700 Subject: [PATCH 02/12] update DB models, create tag db model --- server/src/db/models/Monitor.ts | 9 ++++++++- server/src/db/models/Tag.ts | 33 +++++++++++++++++++++++++++++++++ server/src/db/models/index.ts | 2 ++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 server/src/db/models/Tag.ts diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index a92e9331c..e6bffddd8 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -18,11 +18,12 @@ type CheckSnapshotDocument = Omit & { createdAt: Dat type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; notifications: Types.ObjectId[]; + tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; }; @@ -284,6 +285,12 @@ const MonitorSchema = new Schema( ref: "Notification", }, ], + tags: [ + { + type: Schema.Types.ObjectId, + ref: "Tag", + }, + ], secret: { type: String, }, diff --git a/server/src/db/models/Tag.ts b/server/src/db/models/Tag.ts new file mode 100644 index 000000000..df77aa7ab --- /dev/null +++ b/server/src/db/models/Tag.ts @@ -0,0 +1,33 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Tag as TagEntity } from "@/types/index.js"; + +type TagDocumentBase = Omit; + +interface TagDocument extends TagDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const TagSchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + color: { type: String, required: true, default: "#13715B" }, + }, + { timestamps: true } +); + +TagSchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const TagModel = model("Tag", TagSchema); + +export type { TagDocument }; +export { TagModel }; +export default TagModel; diff --git a/server/src/db/models/index.ts b/server/src/db/models/index.ts index d4c8f1a3b..f0647f9eb 100644 --- a/server/src/db/models/index.ts +++ b/server/src/db/models/index.ts @@ -39,3 +39,5 @@ export { default as GeoCheckModel } from "@/db/models/GeoCheck.js"; export * from "@/db/models/DLQItem.js"; export { default as DLQItemModel } from "@/db/models/DLQItem.js"; +export * from "@/db/models/Tag.js"; +export { default as TagModel } from "@/db/models/Tag.js"; From b1871b31a5399acb0b83b97fe40165698316fbf9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:16 -0700 Subject: [PATCH 03/12] create tags repository, update related repositories --- server/src/repositories/index.ts | 3 + .../monitors/IMonitorsRepository.ts | 1 + .../monitors/MongoMonitorsRepository.ts | 9 +- .../src/repositories/tags/ITagsRepository.ts | 13 +++ .../repositories/tags/MongoTagsRepository.ts | 87 +++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 server/src/repositories/tags/ITagsRepository.ts create mode 100644 server/src/repositories/tags/MongoTagsRepository.ts diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index fc5eefee9..f45768b02 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -25,6 +25,9 @@ export { default as MongoSettingsRepository } from "@/repositories/settings/Mong export * from "@/repositories/notifications/INotificationsRepository.js"; export { default as MongoNotificationsRepository } from "@/repositories/notifications/MongoNotificationsRepository.js"; +export * from "@/repositories/tags/ITagsRepository.js"; +export { default as MongoTagsRepository } from "@/repositories/tags/MongoTagsRepository.js"; + export * from "@/repositories/incidents/IIncidentsRepository.js"; export { default as MongoIncidentsRepository } from "@/repositories/incidents/MongoIncidentsRepository.js"; diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index ac4c8f9eb..ea75e11f6 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -51,6 +51,7 @@ export interface IMonitorsRepository { findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; + removeTagFromMonitors(tagId: string): Promise; updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): 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..9e493e740 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -351,6 +351,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); }; + removeTagFromMonitors = async (tagId: string): Promise => { + await MonitorModel.updateMany({ tags: tagId }, { $pull: { tags: tagId } }); + }; + updateNotifications = async ( teamId: string, monitorIds: string[], @@ -406,7 +410,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification) => toStringId(notification)); - + const tagIds = (doc.tags ?? []).map((tag) => toStringId(tag)); return { id: toStringId(doc._id), userId: toStringId(doc.userId), @@ -429,6 +433,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, @@ -468,6 +473,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; const notificationIds = (doc.notifications ?? []).map((notification: unknown) => toStringId(notification)); + const tagIds = (doc.tags ?? []).map((tag: unknown) => toStringId(tag)); return { id: toStringId(doc._id), @@ -491,6 +497,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { interval: doc.interval, uptimePercentage: doc.uptimePercentage ?? undefined, notifications: notificationIds, + tags: tagIds, secret: doc.secret ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, diff --git a/server/src/repositories/tags/ITagsRepository.ts b/server/src/repositories/tags/ITagsRepository.ts new file mode 100644 index 000000000..42cec2a5a --- /dev/null +++ b/server/src/repositories/tags/ITagsRepository.ts @@ -0,0 +1,13 @@ +import { Tag } from "@/types/index.js"; + +export interface ITagsRepository { + // create + create(tagData: Partial): Promise; + // read + findById(tagId: string, teamId: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(tagId: string, teamId: string, patch: Partial): Promise; + // delete + deleteById(tagId: string, teamId: string): Promise; +} diff --git a/server/src/repositories/tags/MongoTagsRepository.ts b/server/src/repositories/tags/MongoTagsRepository.ts new file mode 100644 index 000000000..72355c7df --- /dev/null +++ b/server/src/repositories/tags/MongoTagsRepository.ts @@ -0,0 +1,87 @@ +import { ITagsRepository } from "@/repositories/tags/ITagsRepository.js"; +import { Tag } from "@/types/tag.js"; +import { TagDocument, TagModel } from "@/db/models/index.js"; +import mongoose from "mongoose"; +import { AppError } from "@/utils/AppError.js"; + +const SERVICE_NAME = "TagsRepository"; + +class MongoTagsRepository implements ITagsRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => { + if (!value) { + return ""; + } + return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value); + }; + + private toDateString = (value?: Date | string | null): string => { + if (!value) { + return new Date(0).toISOString(); + } + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); + }; + + protected toEntity = (doc: TagDocument): Tag => { + return { + id: this.toStringId(doc._id), + teamId: this.toStringId(doc.teamId), + name: doc.name, + color: doc.color, + createdAt: this.toDateString(doc.createdAt), + updatedAt: this.toDateString(doc.updatedAt), + }; + }; + + async create(tagData: Partial): Promise { + try { + const tag = await TagModel.create({ ...tagData }); + return this.toEntity(tag); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A tag named "${tagData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findById(tagId: string, teamId: string): Promise { + const tag = await TagModel.findOne({ _id: tagId, teamId }); + if (!tag) { + throw new AppError({ message: "Tag not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(tag); + } + + async findByTeamId(teamId: string): Promise { + const tags = await TagModel.find({ teamId }); + return tags.map(this.toEntity); + } + + async updateById(tagId: string, teamId: string, patch: Partial): Promise { + const updatedTag = await TagModel.findOneAndUpdate( + { _id: tagId, teamId }, + { + $set: { + ...patch, + }, + }, + { new: true, runValidators: true } + ); + if (!updatedTag) { + throw new AppError({ message: "Tag not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedTag); + } + + async deleteById(tagId: string, teamId: string): Promise { + const deletedTag = await TagModel.findOneAndDelete({ _id: tagId, teamId }); + if (!deletedTag) { + throw new AppError({ message: "Tag not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedTag); + } +} + +export default MongoTagsRepository; From 451c752170747d5e155c748c856bbbbad7f6fbbd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:42 -0700 Subject: [PATCH 04/12] add tags controller and routes --- server/src/controllers/tagController.ts | 98 +++++++++++++++++++++++++ server/src/routes/tagRoutes.ts | 28 +++++++ 2 files changed, 126 insertions(+) create mode 100644 server/src/controllers/tagController.ts create mode 100644 server/src/routes/tagRoutes.ts diff --git a/server/src/controllers/tagController.ts b/server/src/controllers/tagController.ts new file mode 100644 index 000000000..66b21dc6b --- /dev/null +++ b/server/src/controllers/tagController.ts @@ -0,0 +1,98 @@ +import { Request, Response, NextFunction } from "express"; +import { ITagsService } from "@/service/index.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createTagBodyValidation, + editTagBodyValidation, + getTagByIdParamValidation, + editTagParamValidation, + deleteTagParamValidation, +} from "@/validation/index.js"; + +export interface ITagsController { + createTag: (req: Request, res: Response, next: NextFunction) => Promise; + getTagById: (req: Request, res: Response, next: NextFunction) => Promise; + getTagsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; + editTag: (req: Request, res: Response, next: NextFunction) => Promise; + deleteTag: (req: Request, res: Response, next: NextFunction) => Promise; +} + +class TagsController implements ITagsController { + constructor(private tagsService: ITagsService) {} + + createTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const validatedBody = createTagBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const tag = await this.tagsService.createTag(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Tag created successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + getTagById = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = getTagByIdParamValidation.parse(req.params); + const tag = await this.tagsService.getTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag retrieved successfully", + data: tag, + }); + } catch (error) { + next(error); + } + }; + + getTagsByTeamId = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const tags = await this.tagsService.getTagsByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Tags retrieved successfully", + data: tags, + }); + } catch (error) { + next(error); + } + }; + + editTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = editTagParamValidation.parse(req.params); + const validatedBody = editTagBodyValidation.parse(req.body); + const updatedTag = await this.tagsService.updateTag(tagId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Tag updated successfully", + data: updatedTag, + }); + } catch (error) { + next(error); + } + }; + + deleteTag = async (req: Request, res: Response, next: NextFunction) => { + try { + const teamId = requireTeamId(req.user?.teamId); + const { id: tagId } = deleteTagParamValidation.parse(req.params); + await this.tagsService.deleteTag(tagId, teamId); + return res.status(200).json({ + success: true, + msg: "Tag deleted successfully", + }); + } catch (error) { + next(error); + } + }; +} + +export default TagsController; diff --git a/server/src/routes/tagRoutes.ts b/server/src/routes/tagRoutes.ts new file mode 100644 index 000000000..9b237c976 --- /dev/null +++ b/server/src/routes/tagRoutes.ts @@ -0,0 +1,28 @@ +import { ITagsController } from "@/controllers/tagController.js"; +import { Router } from "express"; +class TagRoutes { + private router: Router; + private tagController: ITagsController; + + constructor(tagController: ITagsController) { + this.router = Router(); + this.tagController = tagController; + this.initializeRoutes(); + } + + initializeRoutes() { + this.router.post("/", this.tagController.createTag); + + this.router.get("/team", this.tagController.getTagsByTeamId); + + this.router.get("/:id", this.tagController.getTagById); + this.router.delete("/:id", this.tagController.deleteTag); + this.router.patch("/:id", this.tagController.editTag); + } + + getRouter() { + return this.router; + } +} + +export default TagRoutes; From 48735c7dc849322d471a68006704a06ec6ad9937 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:15:54 -0700 Subject: [PATCH 05/12] add tag service --- server/src/service/index.ts | 1 + .../src/service/infrastructure/tagService.ts | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 server/src/service/infrastructure/tagService.ts diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 5db8afc4f..9bac381f6 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -18,6 +18,7 @@ export * from "@/service/infrastructure/emailService.js"; export * from "@/service/infrastructure/globalPingService.js"; export * from "@/service/infrastructure/networkService.js"; export * from "@/service/infrastructure/notificationsService.js"; +export * from "@/service/infrastructure/tagService.js"; export * from "@/service/infrastructure/statusService.js"; // Notification providers diff --git a/server/src/service/infrastructure/tagService.ts b/server/src/service/infrastructure/tagService.ts new file mode 100644 index 000000000..5dc6f7cef --- /dev/null +++ b/server/src/service/infrastructure/tagService.ts @@ -0,0 +1,37 @@ +import { Tag } from "@/types/index.js"; +import { ITagsRepository, IMonitorsRepository } from "@/repositories/index.js"; +export interface ITagsService { + createTag(tag: Partial, teamId: string): Promise; + getTag(tagId: string, teamId: string): Promise; + getTagsByTeamId(teamId: string): Promise; + updateTag(tagId: string, teamId: string, patch: Partial): Promise; + deleteTag(tagId: string, teamId: string): Promise; +} + +export class TagsService implements ITagsService { + constructor( + private tagsRepository: ITagsRepository, + private monitorsRepository: IMonitorsRepository + ) {} + + createTag = async (tagData: Partial, teamId: string): Promise => { + tagData.teamId = teamId; + return await this.tagsRepository.create(tagData); + }; + + getTag = async (tagId: string, teamId: string): Promise => { + return await this.tagsRepository.findById(tagId, teamId); + }; + + getTagsByTeamId = async (teamId: string): Promise => { + return await this.tagsRepository.findByTeamId(teamId); + }; + + updateTag = async (tagId: string, teamId: string, patch: Partial): Promise => { + return await this.tagsRepository.updateById(tagId, teamId, patch); + }; + deleteTag = async (tagId: string, teamId: string): Promise => { + await this.monitorsRepository.removeTagFromMonitors(tagId); + return await this.tagsRepository.deleteById(tagId, teamId); + }; +} From 57acbd3497c34f1aee2903b0bc7f2784d6b5432a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:03 -0700 Subject: [PATCH 06/12] add validations --- server/src/validation/index.ts | 1 + server/src/validation/monitorValidation.ts | 3 +++ server/src/validation/tagValidation.ts | 25 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 server/src/validation/tagValidation.ts diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index cc1d33372..8627d3892 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -21,3 +21,4 @@ export * from "./statusPageValidation.js"; export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; +export * from "./tagValidation.js"; diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 69d1c2c2a..b668b4cd2 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -78,6 +78,7 @@ export const createMonitorBodyValidation = z diskAlertThreshold: z.number().optional(), tempAlertThreshold: z.number().optional(), notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), secret: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -106,6 +107,7 @@ export const editMonitorBodyValidation = z description: z.union([z.string(), z.literal("")]).optional(), interval: z.number().optional(), notifications: z.array(z.string()).optional(), + tags: z.array(z.string()).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), useAdvancedMatching: z.boolean().optional(), @@ -175,6 +177,7 @@ const importedMonitorSchema = z interval: z.number().default(60000), uptimePercentage: z.number().optional(), notifications: z.array(z.string()).default([]), + tags: z.array(z.string()).default([]), secret: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), diff --git a/server/src/validation/tagValidation.ts b/server/src/validation/tagValidation.ts new file mode 100644 index 000000000..9a5513741 --- /dev/null +++ b/server/src/validation/tagValidation.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +//**************************************** +// Tag Validations +//**************************************** + +export const createTagBodyValidation = z.object({ + name: z.string().min(1, "Tag name is required"), + color: z + .string() + .regex(/^#([0-9a-fA-F]{6})$/, "Color must be a 6-digit hex value (e.g. #13715B)") + .optional(), +}); + +export const editTagBodyValidation = createTagBodyValidation; + +export const getTagByIdParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const editTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); +export const deleteTagParamValidation = z.object({ + id: z.string().min(1, "Tag ID is required"), +}); From 6c2e3b4fe1b5493d7b1c348182b1f29f3b10c355 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:16 -0700 Subject: [PATCH 07/12] init tags --- server/src/config/controllers.ts | 3 +++ server/src/config/routes.ts | 3 +++ server/src/config/services.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 00769f545..e757cfa22 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -9,6 +9,7 @@ import QueueController from "../controllers/queueController.js"; import LogController from "../controllers/logController.js"; import StatusPageController from "../controllers/statusPageController.js"; import NotificationController from "../controllers/notificationController.js"; +import TagsController from "../controllers/tagController.js"; import DiagnosticController from "../controllers/diagnosticController.js"; import IncidentController from "../controllers/incidentController.js"; import type { InitializedServices } from "@/config/services.js"; @@ -25,6 +26,7 @@ export interface InitializedControllers { logController: LogController; statusPageController: StatusPageController; notificationController: NotificationController; + tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; } @@ -41,6 +43,7 @@ export const initializeControllers = (services: InitializedServices): Initialize logController: new LogController(services.logger), statusPageController: new StatusPageController(services.statusPageService, services.monitorsRepository, services.settingsService), notificationController: new NotificationController(services.notificationsService, services.monitorsRepository), + tagController: new TagsController(services.tagsService), diagnosticController: new DiagnosticController(services.diagnosticService), incidentController: new IncidentController(services.incidentService), }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index f36f56e16..cff5e6b1d 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -17,6 +17,7 @@ import QueueRoutes from "../routes/queueRoute.js"; import LogRoutes from "../routes/logRoutes.js"; import DiagnosticRoutes from "../routes/diagnosticRoute.js"; import NotificationRoutes from "../routes/notificationRoute.js"; +import TagRoutes from "../routes/tagRoutes.js"; import IncidentRoutes from "../routes/incidentRoute.js"; @@ -34,6 +35,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const verifyStatusPageAccess = createVerifyStatusPageAccess(services.statusPagesRepository, verifyJWT); const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController, verifyJWT, verifyStatusPageAccess); const notificationRoutes = new NotificationRoutes(controllers.notificationController); + const tagRoutes = new TagRoutes(controllers.tagController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = new IncidentRoutes(controllers.incidentController); @@ -48,6 +50,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/logs", verifyJWT, logRoutes.getRouter()); app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/tags", verifyJWT, tagRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); app.use("/api/v1/incidents", verifyJWT, incidentRoutes.getRouter()); }; diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 309f55242..09c97e1fc 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -9,6 +9,7 @@ import { SuperSimpleQueue, SuperSimpleQueueHelper, NotificationsService, + TagsService, StatusService, NotificationMessageBuilder, MonitorService, @@ -37,6 +38,7 @@ import { IBufferService, ISuperSimpleQueue, INotificationsService, + ITagsService, IStatusService, IMonitorService, IUserService, @@ -98,6 +100,7 @@ import { MongoInvitesRepository, MongoRecoveryTokensRepository, MongoNotificationsRepository, + MongoTagsRepository, MongoIncidentsRepository, MongoTeamsRepository, MongoMaintenanceWindowsRepository, @@ -112,6 +115,7 @@ import { IRecoveryTokensRepository, ISettingsRepository, INotificationsRepository, + ITagsRepository, IIncidentsRepository, ITeamsRepository, IMaintenanceWindowsRepository, @@ -137,6 +141,7 @@ export type InitializedServices = { incidentService: IIncidentService; logger: ILogger; notificationsService: INotificationsService; + tagsService: ITagsService; statusPageService: IStatusPageService; notificationMessageBuilder: INotificationMessageBuilder; @@ -151,6 +156,7 @@ export type InitializedServices = { recoveryTokensRepository: IRecoveryTokensRepository; settingsRepository: ISettingsRepository; notificationsRepository: INotificationsRepository; + tagsRepository: ITagsRepository; incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -191,6 +197,7 @@ export const initializeServices = async ({ let recoveryTokensRepository: IRecoveryTokensRepository; let settingsRepository: ISettingsRepository; let notificationsRepository: INotificationsRepository; + let tagsRepository: ITagsRepository; let incidentsRepository: IIncidentsRepository; let teamsRepository: ITeamsRepository; let maintenanceWindowsRepository: IMaintenanceWindowsRepository; @@ -208,6 +215,7 @@ export const initializeServices = async ({ recoveryTokensRepository = new MongoRecoveryTokensRepository(); settingsRepository = new MongoSettingsRepository(); notificationsRepository = new MongoNotificationsRepository(); + tagsRepository = new MongoTagsRepository(); incidentsRepository = new MongoIncidentsRepository(); teamsRepository = new MongoTeamsRepository(); maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); @@ -293,6 +301,8 @@ export const initializeServices = async ({ notificationMessageBuilder ); + const tagsService = new TagsService(tagsRepository, monitorsRepository); + const superSimpleQueueHelper = new SuperSimpleQueueHelper( logger, networkService, @@ -372,6 +382,7 @@ export const initializeServices = async ({ incidentService, logger, notificationsService, + tagsService, statusPageService, notificationMessageBuilder, @@ -386,6 +397,7 @@ export const initializeServices = async ({ recoveryTokensRepository, settingsRepository, notificationsRepository, + tagsRepository, incidentsRepository, teamsRepository, maintenanceWindowsRepository, From 9b84b98391a072ff4bc837030cb8175c8d0fb2d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 11:16:25 -0700 Subject: [PATCH 08/12] add tests --- server/test/unit/services/tagsService.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 server/test/unit/services/tagsService.test.ts diff --git a/server/test/unit/services/tagsService.test.ts b/server/test/unit/services/tagsService.test.ts new file mode 100644 index 000000000..d611d641e --- /dev/null +++ b/server/test/unit/services/tagsService.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { TagsService } from "../../../src/service/infrastructure/tagService.ts"; +import type { Tag } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createTagsRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + removeTagFromMonitors: jest.fn(), +}); + +const createService = (overrides?: Record) => { + const tagsRepository = createTagsRepo(); + const monitorsRepository = createMonitorsRepo(); + + const defaults = { + tagsRepository, + monitorsRepository, + ...overrides, + }; + + const service = new TagsService(defaults.tagsRepository as any, defaults.monitorsRepository as any); + + return { service, ...defaults }; +}; + +const makeTag = (overrides?: Partial): Tag => + ({ + id: "tag-1", + teamId: "team-1", + name: "Production", + color: "#13715B", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Tag; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("TagsService", () => { + describe("createTag", () => { + it("sets teamId and delegates to repository", async () => { + const created = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.createTag({ name: "Production", color: "#13715B" }, "team-1"); + + expect(result).toBe(created); + expect(tagsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Production", color: "#13715B" })); + }); + }); + + describe("getTag", () => { + it("delegates to repository", async () => { + const tag = makeTag(); + const { service, tagsRepository } = createService(); + (tagsRepository.findById as jest.Mock).mockResolvedValue(tag); + + const result = await service.getTag("tag-1", "team-1"); + + expect(result).toBe(tag); + expect(tagsRepository.findById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + }); + + describe("getTagsByTeamId", () => { + it("delegates to repository", async () => { + const tags = [makeTag()]; + const { service, tagsRepository } = createService(); + (tagsRepository.findByTeamId as jest.Mock).mockResolvedValue(tags); + + const result = await service.getTagsByTeamId("team-1"); + + expect(result).toBe(tags); + expect(tagsRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + }); + }); + + describe("updateTag", () => { + it("delegates to repository", async () => { + const updated = makeTag({ name: "Staging" }); + const { service, tagsRepository } = createService(); + (tagsRepository.updateById as jest.Mock).mockResolvedValue(updated); + + const result = await service.updateTag("tag-1", "team-1", { name: "Staging" }); + + expect(result).toBe(updated); + expect(tagsRepository.updateById).toHaveBeenCalledWith("tag-1", "team-1", { name: "Staging" }); + }); + }); + + describe("deleteTag", () => { + it("removes the tag from monitors and deletes it", async () => { + const deleted = makeTag(); + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); + + const result = await service.deleteTag("tag-1", "team-1"); + + expect(result).toBe(deleted); + expect(monitorsRepository.removeTagFromMonitors).toHaveBeenCalledWith("tag-1"); + expect(tagsRepository.deleteById).toHaveBeenCalledWith("tag-1", "team-1"); + }); + + it("detaches the tag from monitors before deleting it", async () => { + const { service, tagsRepository, monitorsRepository } = createService(); + (tagsRepository.deleteById as jest.Mock).mockResolvedValue(makeTag()); + + await service.deleteTag("tag-1", "team-1"); + + const removeOrder = (monitorsRepository.removeTagFromMonitors as jest.Mock).mock.invocationCallOrder[0]; + const deleteOrder = (tagsRepository.deleteById as jest.Mock).mock.invocationCallOrder[0]; + expect(removeOrder).toBeLessThan(deleteOrder); + }); + }); +}); From a7abbd60bd91762f8df3c2872db10ec47003a634 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:44:18 -0700 Subject: [PATCH 09/12] add tags to monitors repo --- .../src/repositories/monitors/IMonitorsRepository.ts | 1 + .../repositories/monitors/MongoMonitorsRepository.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index ea75e11f6..6103132f8 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -3,6 +3,7 @@ import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } f export interface TeamQueryConfig { limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 9e493e740..24a3cb7d8 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -44,7 +44,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { - const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {}; + const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, tags } = config ?? {}; const query: Record = { teamId: new mongoose.Types.ObjectId(teamId), @@ -54,6 +54,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + if (filter !== undefined) { switch (field) { case "name": @@ -152,7 +156,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findMonitorCountByTeamIdAndType = async (teamId: string, config?: TeamQueryConfig): Promise => { - const { type } = config ?? {}; + const { type, tags } = config ?? {}; const query: FilterQuery = { teamId: new mongoose.Types.ObjectId(teamId), @@ -162,6 +166,10 @@ class MongoMonitorsRepository implements IMonitorsRepository { query.type = Array.isArray(type) ? { $in: type } : type; } + if (tags !== undefined) { + query.tags = Array.isArray(tags) ? { $in: tags } : tags; + } + const count = await MonitorModel.countDocuments(query); return count; }; From 029f732a8618fdc234d4a4062bc5120ed2b21a3a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:44:56 -0700 Subject: [PATCH 10/12] add tags to monitor service --- server/src/service/business/monitorService.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 72c8f1fe8..fc08200c5 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -55,6 +55,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -65,6 +66,7 @@ export interface IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -357,19 +359,22 @@ export class MonitorService implements IMonitorService { getMonitorsByTeamId = async ({ teamId, type, + tags, filter, }: { teamId: string; type?: MonitorType | MonitorType[]; + tags?: string | string[]; filter?: string; }): Promise => { - return await this.monitorsRepository.findByTeamId(teamId, { type, filter }); + return await this.monitorsRepository.findByTeamId(teamId, { type, tags, filter }); }; getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, + tags, page, rowsPerPage, filter, @@ -379,6 +384,7 @@ export class MonitorService implements IMonitorService { teamId: string; limit?: number; type?: MonitorType | MonitorType[]; + tags?: string | string[]; page?: number; rowsPerPage?: number; filter?: string; @@ -386,10 +392,11 @@ export class MonitorService implements IMonitorService { order?: "asc" | "desc"; }): Promise => { const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type }); - const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter }); + const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, tags, filter }); const monitors = await this.monitorsRepository.findByTeamId(teamId, { limit, type, + tags, page, rowsPerPage, filter, From 76ce362eeaed577e160d7a9298b659c82c5cc9b6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:45:29 -0700 Subject: [PATCH 11/12] add tags to monitor controller --- server/src/controllers/monitorController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 5cb6f32be..92289eb22 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -357,9 +357,10 @@ class MonitorController implements IMonitorController { const teamId = requireTeamId(req.user?.teamId); const type = validatedQuery.type; + const tags = validatedQuery.tags; const filter = validatedQuery.filter; - const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter }); + const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, tags, filter }); return res.status(200).json({ success: true, @@ -382,12 +383,14 @@ class MonitorController implements IMonitorController { const field = validatedQuery.field; const order = validatedQuery.order; const type = validatedQuery.type; + const tags = validatedQuery.tags; const teamId = requireTeamId(req.user?.teamId); const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({ teamId, limit, type, + tags, page, rowsPerPage, filter, From 2a2f101d4be53a17973a9b901b0a37616da517fa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 19 May 2026 14:45:42 -0700 Subject: [PATCH 12/12] add tests and validation --- server/src/validation/monitorValidation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index b668b4cd2..d5164b8ef 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -22,6 +22,7 @@ export const getMonitorsByTeamIdParamValidation = z.object({}); export const getMonitorsByTeamIdQueryValidation = z.object({ type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), filter: z.union([z.string(), z.literal("")]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), }); export const getMonitorsWithChecksQueryValidation = z.object({ @@ -32,6 +33,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ field: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), type: z.union([z.enum(MonitorTypes), z.array(z.enum(MonitorTypes))]).optional(), + tags: z.union([z.string(), z.array(z.string())]).optional(), explain: booleanCoercion.optional(), }); @@ -238,6 +240,7 @@ export const monitorResponseSchema = z expectedValue: z.string().optional(), matchMethod: z.enum(MonitorMatchMethods).optional(), notifications: z.array(z.string()), + tags: z.array(z.string()), secret: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(),