Merge pull request #3661 from bluewave-labs/feat/monitor-tags

feat: monitor tags
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-05-20 09:20:18 -07:00
commit 9c62dc9c52
24 files changed
+527 -7

No files matched your search

+3
View File
@@ -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),
};
+3
View File
@@ -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());
};
+12
View File
@@ -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,
+4 -1
View File
@@ -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,
+98
View File
@@ -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<Response | void>;
getTagById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getTagsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editTag: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteTag: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
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;
+8 -1
View File
@@ -18,11 +18,12 @@ type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { 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<MonitorDocument>(
ref: "Notification",
},
],
tags: [
{
type: Schema.Types.ObjectId,
ref: "Tag",
},
],
secret: {
type: String,
},
+33
View File
@@ -0,0 +1,33 @@
import { Schema, model, type Types } from "mongoose";
import type { Tag as TagEntity } from "@/types/index.js";
type TagDocumentBase = Omit<TagEntity, "id" | "teamId" | "createdAt" | "updatedAt">;
interface TagDocument extends TagDocumentBase {
_id: Types.ObjectId;
teamId: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const TagSchema = new Schema<TagDocument>(
{
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<TagDocument>("Tag", TagSchema);
export type { TagDocument };
export { TagModel };
export default TagModel;
+2
View File
@@ -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";
+3
View File
@@ -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";
@@ -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;
@@ -51,6 +52,7 @@ export interface IMonitorsRepository {
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
findGroupsByTeamId(teamId: string): Promise<string[]>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
removeTagFromMonitors(tagId: string): Promise<void>;
updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise<number>;
deleteByTeamIdsNotIn(teamIds: string[]): Promise<number>;
findAllMonitorIds(): Promise<string[]>;
@@ -44,7 +44,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
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<string, unknown> = {
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<number> => {
const { type } = config ?? {};
const { type, tags } = config ?? {};
const query: FilterQuery<MonitorDocument> = {
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;
};
@@ -351,6 +359,10 @@ class MongoMonitorsRepository implements IMonitorsRepository {
await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } });
};
removeTagFromMonitors = async (tagId: string): Promise<void> => {
await MonitorModel.updateMany({ tags: tagId }, { $pull: { tags: tagId } });
};
updateNotifications = async (
teamId: string,
monitorIds: string[],
@@ -406,7 +418,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 +441,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 +481,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 +505,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,
@@ -0,0 +1,13 @@
import { Tag } from "@/types/index.js";
export interface ITagsRepository {
// create
create(tagData: Partial<Tag>): Promise<Tag>;
// read
findById(tagId: string, teamId: string): Promise<Tag>;
findByTeamId(teamId: string): Promise<Tag[]>;
// update
updateById(tagId: string, teamId: string, patch: Partial<Tag>): Promise<Tag>;
// delete
deleteById(tagId: string, teamId: string): Promise<Tag>;
}
@@ -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<Tag>): Promise<Tag> {
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<Tag> {
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<Tag[]> {
const tags = await TagModel.find({ teamId });
return tags.map(this.toEntity);
}
async updateById(tagId: string, teamId: string, patch: Partial<Tag>): Promise<Tag> {
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<Tag> {
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;
+28
View File
@@ -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;
@@ -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<Monitor[] | null> => {
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<MonitorsWithChecksByTeamIdResult> => {
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,
+1
View File
@@ -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
@@ -0,0 +1,37 @@
import { Tag } from "@/types/index.js";
import { ITagsRepository, IMonitorsRepository } from "@/repositories/index.js";
export interface ITagsService {
createTag(tag: Partial<Tag>, teamId: string): Promise<Tag>;
getTag(tagId: string, teamId: string): Promise<Tag>;
getTagsByTeamId(teamId: string): Promise<Tag[]>;
updateTag(tagId: string, teamId: string, patch: Partial<Tag>): Promise<Tag>;
deleteTag(tagId: string, teamId: string): Promise<Tag>;
}
export class TagsService implements ITagsService {
constructor(
private tagsRepository: ITagsRepository,
private monitorsRepository: IMonitorsRepository
) {}
createTag = async (tagData: Partial<Tag>, teamId: string): Promise<Tag> => {
tagData.teamId = teamId;
return await this.tagsRepository.create(tagData);
};
getTag = async (tagId: string, teamId: string): Promise<Tag> => {
return await this.tagsRepository.findById(tagId, teamId);
};
getTagsByTeamId = async (teamId: string): Promise<Tag[]> => {
return await this.tagsRepository.findByTeamId(teamId);
};
updateTag = async (tagId: string, teamId: string, patch: Partial<Tag>): Promise<Tag> => {
return await this.tagsRepository.updateById(tagId, teamId, patch);
};
deleteTag = async (tagId: string, teamId: string): Promise<Tag> => {
await this.monitorsRepository.removeTagFromMonitors(tagId);
return await this.tagsRepository.deleteById(tagId, teamId);
};
}
+1
View File
@@ -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";
+1
View File
@@ -59,6 +59,7 @@ export interface Monitor {
interval: number;
uptimePercentage?: number;
notifications: string[];
tags: string[];
secret?: string;
cpuAlertThreshold: number;
cpuAlertCounter: number;
+8
View File
@@ -0,0 +1,8 @@
export interface Tag {
id: string;
teamId: string;
name: string;
color: string;
createdAt: string;
updatedAt: string;
}
+1
View File
@@ -21,3 +21,4 @@ export * from "./statusPageValidation.js";
export * from "./notificationValidation.js";
export * from "./userValidation.js";
export * from "./incidentValidation.js";
export * from "./tagValidation.js";
@@ -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(),
});
@@ -78,6 +80,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 +109,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 +179,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),
@@ -235,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(),
+25
View File
@@ -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"),
});
@@ -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<string, unknown>) => {
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>): 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);
});
});
});