Merge pull request #3661 from bluewave-labs/feat/monitor-tags
feat: monitor tags
This commit is contained in:
24 files changed
+527
-7
No files matched your search
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -59,6 +59,7 @@ export interface Monitor {
|
||||
interval: number;
|
||||
uptimePercentage?: number;
|
||||
notifications: string[];
|
||||
tags: string[];
|
||||
secret?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Tag {
|
||||
id: string;
|
||||
teamId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user