diff --git a/server/src/config/controllers.js b/server/src/config/controllers.js index 5c54579eb..78cd7091b 100644 --- a/server/src/config/controllers.js +++ b/server/src/config/controllers.js @@ -2,7 +2,7 @@ import { createCommonDependencies } from "../controllers/v1/baseController.js"; // Services -// Controllers +// V1 Controllers import MonitorController from "../controllers/v1/monitorController.js"; import AuthController from "../controllers/v1/authController.js"; import SettingsController from "../controllers/v1/settingsController.js"; @@ -15,10 +15,18 @@ import StatusPageController from "../controllers/v1/statusPageController.js"; import NotificationController from "../controllers/v1/notificationController.js"; import DiagnosticController from "../controllers/v1/diagnosticController.js"; +// V2 Controllers +import AuthControllerV2 from "../controllers/v2/AuthController.js"; +import InviteControllerV2 from "../controllers/v2/InviteController.js"; +import MaintenanceControllerV2 from "../controllers/v2/MaintenanceController.js"; +import MonitorControllerV2 from "../controllers/v2/MonitorController.js"; +import NotificationChannelControllerV2 from "../controllers/v2/NotificationChannelController.js"; +import QueueControllerV2 from "../controllers/v2/QueueController.js"; export const initializeControllers = (services) => { const controllers = {}; const commonDependencies = createCommonDependencies(services.db, services.errorService, services.logger, services.stringService); + // V1 controllers.authController = new AuthController(commonDependencies, { settingsService: services.settingsService, emailService: services.emailService, @@ -62,5 +70,13 @@ export const initializeControllers = (services) => { diagnosticService: services.diagnosticService, }); + //V2 + controllers.authControllerV2 = new AuthControllerV2(services.authServiceV2, services.inviteServiceV2); + controllers.inviteControllerV2 = new InviteControllerV2(services.inviteServiceV2); + controllers.maintenanceControllerV2 = new MaintenanceControllerV2(services.maintenanceServiceV2); + controllers.monitorControllerV2 = new MonitorControllerV2(services.monitorServiceV2); + controllers.notificationChannelControllerV2 = new NotificationChannelControllerV2(services.notificationChannelServiceV2); + controllers.queueControllerV2 = new QueueControllerV2(services.jobQueueV2); + return controllers; }; diff --git a/server/src/config/routes.js b/server/src/config/routes.js index 40bf8e994..13b369262 100644 --- a/server/src/config/routes.js +++ b/server/src/config/routes.js @@ -13,7 +13,16 @@ import LogRoutes from "../routes/v1/logRoutes.js"; import DiagnosticRoutes from "../routes/v1//diagnosticRoute.js"; import NotificationRoutes from "../routes/v1/notificationRoute.js"; +//V2 +import AuthRoutesV2 from "../routes/v2/auth.js"; +import InviteRoutesV2 from "../routes/v2/invite.js"; +import MaintenanceRoutesV2 from "../routes/v2/maintenance.js"; +import MonitorRoutesV2 from "../routes/v2/monitors.js"; +import NotificationChannelRoutesV2 from "../routes/v2/notificationChannels.js"; +import QueueRoutesV2 from "../routes/v2/queue.js"; + export const setupRoutes = (app, controllers) => { + // V1 const authRoutes = new AuthRoutes(controllers.authController); const monitorRoutes = new MonitorRoutes(controllers.monitorController); const settingsRoutes = new SettingsRoutes(controllers.settingsController); @@ -37,4 +46,19 @@ export const setupRoutes = (app, controllers) => { app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); + + // V2 + const authRoutesV2 = new AuthRoutesV2(controllers.authControllerV2); + const inviteRoutesV2 = new InviteRoutesV2(controllers.inviteControllerV2); + const maintenanceRoutesV2 = new MaintenanceRoutesV2(controllers.maintenanceControllerV2); + const monitorRoutesV2 = new MonitorRoutesV2(controllers.monitorControllerV2); + const notificationChannelRoutesV2 = new NotificationChannelRoutesV2(controllers.notificationChannelControllerV2); + const queueRoutesV2 = new QueueRoutesV2(controllers.queueControllerV2); + + app.use("/api/v2/auth", authApiLimiter, authRoutesV2.getRouter()); + app.use("/api/v2/invite", inviteRoutesV2.getRouter()); + app.use("/api/v2/maintenance", maintenanceRoutesV2.getRouter()); + app.use("/api/v2/monitors", monitorRoutesV2.getRouter()); + app.use("/api/v2/notification-channels", notificationChannelRoutesV2.getRouter()); + app.use("/api/v2/queue", queueRoutesV2.getRouter()); }; diff --git a/server/src/config/services.js b/server/src/config/services.js index 22f978071..ec07c5e15 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -61,13 +61,35 @@ import AppSettings from "../db/v1/models/AppSettings.js"; import InviteModule from "../db/v1/modules/inviteModule.js"; import CheckModule from "../db/v1/modules/checkModule.js"; import StatusPageModule from "../db/v1/modules/statusPageModule.js"; -import UserModule from "../db/v1/modules//userModule.js"; +import UserModule from "../db/v1/modules/userModule.js"; import MaintenanceWindowModule from "../db/v1/modules/maintenanceWindowModule.js"; import MonitorModule from "../db/v1/modules/monitorModule.js"; import NotificationModule from "../db/v1/modules/notificationModule.js"; import RecoveryModule from "../db/v1/modules/recoveryModule.js"; import SettingsModule from "../db/v1/modules/settingsModule.js"; +// V2 Business +import AuthServiceV2 from "../service/v2/business/AuthService.js"; +import CheckServiceV2 from "../service/v2/business/CheckService.js"; +import InviteServiceV2 from "../service/v2/business/InviteService.js"; +import MaintenanceServiceV2 from "../service/v2/business/MaintenanceService.js"; +import MonitorServiceV2 from "../service/v2/business/MonitorService.js"; +import MonitorStatsServiceV2 from "../service/v2/business/MonitorStatsService.js"; +import NotificationChannelServiceV2 from "../service/v2/business/NotificationChannelService.js"; +import QueueServiceV2 from "../service/v2/business/QueueService.js"; +import UserServiceV2 from "../service/v2/business/UserService.js"; + +// V2 Infra +import DiscordServiceV2 from "../service/v2/infrastructure/NotificationServices/Discord.js"; +import EmailServiceV2 from "../service/v2/infrastructure/NotificationServices/Email.js"; +import SlackServiceV2 from "../service/v2/infrastructure/NotificationServices/Slack.js"; +import WebhookServiceV2 from "../service/v2/infrastructure/NotificationServices/Webhook.js"; +import JobGeneratorV2 from "../service/v2/infrastructure/JobGenerator.js"; +import JobQueueV2 from "../service/v2/infrastructure/JobQueue.js"; +import NetworkServiceV2 from "../service/v2/infrastructure/NetworkService.js"; +import NotificationServiceV2 from "../service/v2/infrastructure/NotificationService.js"; +import StatusServiceV2 from "../service/v2/infrastructure/StatusService.js"; + export const initializeServices = async ({ logger, envSettings, settingsService }) => { const serviceRegistry = new ServiceRegistry({ logger }); ServiceRegistry.instance = serviceRegistry; @@ -208,7 +230,37 @@ export const initializeServices = async ({ logger, envSettings, settingsService games, }); + // V2 Services + const jobQueueV2 = new JobQueueV2(); + const authServiceV2 = new AuthServiceV2(jobQueueV2); + const checkServiceV2 = new CheckServiceV2(); + const inviteServiceV2 = new InviteServiceV2(); + const maintenanceServiceV2 = new MaintenanceServiceV2(); + const monitorServiceV2 = new MonitorServiceV2(jobQueueV2); + const monitorStatsServiceV2 = new MonitorStatsServiceV2(); + const notificationChannelServiceV2 = new NotificationChannelServiceV2(); + const queueServiceV2 = new QueueServiceV2(jobQueueV2); + const userServiceV2 = new UserServiceV2(); + + // V2 Infra + const discordServiceV2 = new DiscordServiceV2(); + const emailServiceV2 = new EmailServiceV2(userServiceV2); + const slackServiceV2 = new SlackServiceV2(); + const webhookServiceV2 = new WebhookServiceV2(); + const networkServiceV2 = new NetworkServiceV2(); + const statusServiceV2 = new StatusServiceV2(); + const notificationServiceV2 = new NotificationServiceV2(userServiceV2); + const jobGeneratorV2 = new JobGeneratorV2( + networkServiceV2, + checkServiceV2, + monitorStatsServiceV2, + statusServiceV2, + notificationServiceV2, + maintenanceServiceV2 + ); + const services = { + //v1 settingsService, translationService, stringService, @@ -227,6 +279,25 @@ export const initializeServices = async ({ logger, envSettings, settingsService monitorService, errorService, logger, + //v2 + jobQueueV2, + authServiceV2, + checkServiceV2, + inviteServiceV2, + maintenanceServiceV2, + monitorServiceV2, + monitorStatsServiceV2, + notificationChannelServiceV2, + queueServiceV2, + userServiceV2, + discordServiceV2, + emailServiceV2, + slackServiceV2, + webhookServiceV2, + networkServiceV2, + statusServiceV2, + notificationServiceV2, + jobGeneratorV2, }; Object.values(services).forEach((service) => { diff --git a/server/src/controllers/v2/AuthController.ts b/server/src/controllers/v2/AuthController.ts new file mode 100644 index 000000000..3afed5312 --- /dev/null +++ b/server/src/controllers/v2/AuthController.ts @@ -0,0 +1,150 @@ +import { Request, Response, NextFunction } from "express"; +import { encode, decode } from "../../utils/JWTUtils.js"; +import AuthService from "../../service/v2/business/AuthService.js"; +import ApiError from "../../utils/ApiError.js"; +import InviteService from "../../service/v2/business/InviteService.js"; +import { IInvite } from "../../db/v2/models/index.js"; + +class AuthController { + private authService: AuthService; + private inviteService: InviteService; + constructor(authService: AuthService, inviteService: InviteService) { + this.authService = authService; + this.inviteService = inviteService; + } + + register = async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, firstName, lastName, password } = req.body; + + if (!email || !firstName || !lastName || !password) { + throw new Error("Email, firstName, lastName, and password are required"); + } + + const result = await this.authService.register({ + email, + firstName, + lastName, + password, + }); + + const token = encode(result); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(201).json({ + message: "User created successfully", + }); + } catch (error) { + next(error); + } + }; + + registerWithInvite = async (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.params.token; + if (!token) { + throw new ApiError("Invite token is required", 400); + } + + const invite: IInvite = await this.inviteService.get(token); + + const { firstName, lastName, password } = req.body; + const email = invite?.email; + const roles = invite?.roles; + + if (!email || !firstName || !lastName || !password || !roles || roles.length === 0) { + throw new Error("Email, firstName, lastName, password, and roles are required"); + } + + const result = await this.authService.registerWithInvite({ + email, + firstName, + lastName, + password, + roles, + }); + + if (!result) { + throw new Error("Registration failed"); + } + + await this.inviteService.delete(invite._id.toString()); + + const jwt = encode(result); + + res.cookie("token", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(201).json({ message: "User created successfully" }); + } catch (error) { + next(error); + } + }; + + login = async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, password } = req.body; + // Validation + if (!email || !password) { + return res.status(400).json({ message: "Email and password are required" }); + } + const result = await this.authService.login({ email, password }); + + const token = encode(result); + + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week + }); + + res.status(200).json({ + message: "Login successful", + }); + } catch (error) { + next(error); + } + }; + + logout = (req: Request, res: Response) => { + res.clearCookie("token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + res.status(200).json({ message: "Logout successful" }); + }; + + me = (req: Request, res: Response, next: NextFunction) => { + return res.status(200).json({ message: "OK" }); + }; + + cleanup = async (req: Request, res: Response) => { + try { + await this.authService.cleanup(); + res.status(200).json({ message: "Cleanup successful" }); + } catch (error) {} + }; + + cleanMonitors = async (req: Request, res: Response) => { + try { + await this.authService.cleanMonitors(); + res.status(200).json({ message: "Monitors cleanup successful" }); + } catch (error) { + res.status(500).json({ message: "Internal server error" }); + } + }; +} + +export default AuthController; diff --git a/server/src/controllers/v2/InviteController.ts b/server/src/controllers/v2/InviteController.ts new file mode 100644 index 000000000..4d72bf8d4 --- /dev/null +++ b/server/src/controllers/v2/InviteController.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import InviteService from "../../service/v2/business/InviteService.js"; + +class InviteController { + private inviteService: InviteService; + constructor(inviteService: InviteService) { + this.inviteService = inviteService; + } + + create = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const invite = await this.inviteService.create(tokenizedUser, req.body); + res.status(201).json({ message: "OK", data: invite }); + } catch (error: any) { + next(error); + } + }; + + getAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const invites = await this.inviteService.getAll(); + res.status(200).json({ + message: "OK", + data: invites, + }); + } catch (error) { + next(error); + } + }; + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.params.token; + if (!token) { + return res.status(400).json({ message: "Token parameter is required" }); + } + const invite = await this.inviteService.get(token); + res.status(200).json({ message: "OK", data: invite }); + } catch (error) { + next(error); + } + }; + + delete = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + await this.inviteService.delete(id); + res.status(204).json({ message: "OK" }); + } catch (error: any) { + next(error); + } + }; +} + +export default InviteController; diff --git a/server/src/controllers/v2/MaintenanceController.ts b/server/src/controllers/v2/MaintenanceController.ts new file mode 100644 index 000000000..7c6be11d5 --- /dev/null +++ b/server/src/controllers/v2/MaintenanceController.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from "express"; +import MaintenanceService from "../../service/v2/business/MaintenanceService.js"; + +class MaintenanceController { + private maintenanceService: MaintenanceService; + constructor(maintenanceService: MaintenanceService) { + this.maintenanceService = maintenanceService; + } + + create = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const maintenance = await this.maintenanceService.create(tokenizedUser, req.body); + res.status(201).json({ message: "OK", data: maintenance }); + } catch (error) { + next(error); + } + }; + + getAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const maintenances = await this.maintenanceService.getAll(); + res.status(200).json({ + message: "OK", + data: maintenances, + }); + } catch (error) { + next(error); + } + }; + + toggleActive = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const maintenance = await this.maintenanceService.toggleActive(tokenizedUser, id); + res.status(200).json({ message: "OK", data: maintenance }); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const updatedMaintenance = await this.maintenanceService.update(tokenizedUser, id, req.body); + res.status(200).json({ message: "OK", data: updatedMaintenance }); + } catch (error) { + next(error); + } + }; + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const maintenance = await this.maintenanceService.get(id); + res.status(200).json({ message: "OK", data: maintenance }); + } catch (error) { + next(error); + } + }; + + delete = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + await this.maintenanceService.delete(id); + res.status(204).json({ message: "OK" }); + } catch (error) { + next(error); + } + }; +} + +export default MaintenanceController; diff --git a/server/src/controllers/v2/MonitorController.ts b/server/src/controllers/v2/MonitorController.ts new file mode 100644 index 000000000..c9a4ce7da --- /dev/null +++ b/server/src/controllers/v2/MonitorController.ts @@ -0,0 +1,157 @@ +import { Request, Response, NextFunction } from "express"; +import ApiError from "../../utils/ApiError.js"; +import MonitorService from "../../service/v2/business/MonitorService.js"; +import { MonitorType } from "../../db/v2/models/monitors/Monitor.js"; +class MonitorController { + private monitorService: MonitorService; + constructor(monitorService: MonitorService) { + this.monitorService = monitorService; + } + + create = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const monitor = await this.monitorService.create(tokenizedUser, req.body); + res.status(201).json({ + message: "Monitor created successfully", + data: monitor, + }); + } catch (error) { + next(error); + } + }; + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + + const range = req.query.range; + if (!range || typeof range !== "string") throw new ApiError("Range query parameter is required", 400); + + let monitor; + + const status = req.query.status; + if (status && typeof status !== "string") { + throw new ApiError("Status query parameter must be a string", 400); + } + + if (req.query.embedChecks === "true") { + monitor = await this.monitorService.getEmbedChecks(id, range, status); + } else { + monitor = await this.monitorService.get(id); + } + + res.status(200).json({ + message: "Monitor retrieved successfully", + data: monitor, + }); + } catch (error) { + next(error); + } + }; + + getAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + let monitors; + if (req.query.embedChecks === "true") { + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.max(1, Number(req.query.limit) || 10); + const type: MonitorType[] = req.query.type as MonitorType[]; + + monitors = await this.monitorService.getAllEmbedChecks(page, limit, type); + } else { + monitors = await this.monitorService.getAll(); + } + + res.status(200).json({ + message: "Monitors retrieved successfully", + data: monitors, + }); + } catch (error) { + next(error); + } + }; + + toggleActive = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + + const monitor = await this.monitorService.toggleActive(id, tokenizedUser); + res.status(200).json({ + message: "Monitor paused/unpaused successfully", + data: monitor, + }); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + + const monitor = await this.monitorService.update(tokenizedUser, id, req.body); + res.status(200).json({ + message: "Monitor updated successfully", + data: monitor, + }); + } catch (error) { + next(error); + } + }; + + delete = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const id = req.params.id; + if (!id) { + throw new ApiError("Monitor ID is required", 400); + } + await this.monitorService.delete(id); + + res.status(200).json({ + message: "Monitor deleted successfully", + }); + } catch (error) { + next(error); + } + }; +} + +export default MonitorController; diff --git a/server/src/controllers/v2/NotificationChannelController.ts b/server/src/controllers/v2/NotificationChannelController.ts new file mode 100644 index 000000000..e13f572f4 --- /dev/null +++ b/server/src/controllers/v2/NotificationChannelController.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction } from "express"; +import NotificationService from "../../service/v2/business/NotificationChannelService.js"; + +class NotificationChannelController { + private notificationService: NotificationService; + constructor(notificationService: NotificationService) { + this.notificationService = notificationService; + } + + create = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const channel = await this.notificationService.create(tokenizedUser, req.body); + res.status(201).json({ message: "OK", data: channel }); + } catch (error) { + next(error); + } + }; + + getAll = async (req: Request, res: Response, next: NextFunction) => { + try { + const notificationChannels = await this.notificationService.getAll(); + res.status(200).json({ + message: "OK", + data: notificationChannels, + }); + } catch (error) { + next(error); + } + }; + + toggleActive = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const notificationChannel = await this.notificationService.toggleActive(tokenizedUser, id); + res.status(200).json({ message: "OK", data: notificationChannel }); + } catch (error) { + next(error); + } + }; + + update = async (req: Request, res: Response, next: NextFunction) => { + try { + const tokenizedUser = req.user; + if (!tokenizedUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const updatedChannel = await this.notificationService.update(tokenizedUser, id, req.body); + res.status(200).json({ message: "OK", data: updatedChannel }); + } catch (error) { + next(error); + } + }; + + get = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + const notificationChannel = await this.notificationService.get(id); + res.status(200).json({ message: "OK", data: notificationChannel }); + } catch (error) { + next(error); + } + }; + + delete = async (req: Request, res: Response, next: NextFunction) => { + try { + const id = req.params.id; + if (!id) { + return res.status(400).json({ message: "ID parameter is required" }); + } + await this.notificationService.delete(id); + res.status(204).json({ message: "OK" }); + } catch (error) { + next(error); + } + }; +} + +export default NotificationChannelController; diff --git a/server/src/controllers/v2/QueueController.ts b/server/src/controllers/v2/QueueController.ts new file mode 100644 index 000000000..510703894 --- /dev/null +++ b/server/src/controllers/v2/QueueController.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from "express"; +import QueueService from "../../service/v2/business/QueueService.js"; +class QueueController { + private queueService: QueueService; + constructor(queueService: QueueService) { + this.queueService = queueService; + } + + getJobs = async (req: Request, res: Response, next: NextFunction) => { + try { + const jobs = await this.queueService.getJobs(); + res.status(200).json({ message: "ok", data: jobs }); + } catch (error) { + next(error); + } + }; + + getMetrics = async (req: Request, res: Response, next: NextFunction) => { + const metrics = await this.queueService.getMetrics(); + res.status(200).json({ message: "ok", data: metrics }); + }; + + flush = async (req: Request, res: Response, next: NextFunction) => { + const result = await this.queueService.flush(); + res.status(200).json({ message: "ok", flushed: result }); + }; +} + +export default QueueController; diff --git a/server/src/middleware/v2/VerifyPermissions.ts b/server/src/middleware/v2/VerifyPermissions.ts new file mode 100644 index 000000000..64d573e00 --- /dev/null +++ b/server/src/middleware/v2/VerifyPermissions.ts @@ -0,0 +1,77 @@ +import { Request, Response, NextFunction } from "express"; +import ApiError from "../../utils/ApiError.js"; +import { User, IUser, Role, IRole } from "../../db/v2/models/index.js"; + +const rolesCache = new Map(); +// const CACHE_TTL = 30 * 60 * 1000; // 30 minutes +const CACHE_TTL = 1; // 30 minutes +const MAX_CACHE_SIZE = 1000; + +const getCachedRoles = async (userId: string) => { + if (rolesCache.size >= MAX_CACHE_SIZE) { + const oldestKey = rolesCache.keys().next().value; + if (!oldestKey) return null; + rolesCache.delete(oldestKey); + } + + const cached = rolesCache.get(userId); + + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.roles; + } + + const user: IUser | null = await User.findById(userId); + if (!user) { + return null; + } + + const roles = await Role.find({ _id: { $in: user.roles } }); + rolesCache.set(userId, { roles, timestamp: Date.now() }); + return roles; +}; + +const hasPermission = (roles: IRole[], requiredPermissions: string[]) => { + const userPermissions = [...new Set(roles.flatMap((role) => role.permissions))]; + + if (userPermissions.includes("*")) return true; + + const matches = (requiredPermission: string, userPermission: string) => { + if (userPermission === requiredPermission) return true; + if (userPermission.endsWith(".*")) { + const prefix = userPermission.slice(0, -2); + return requiredPermission.startsWith(prefix + "."); + } + return false; + }; + + return requiredPermissions.every((requiredPermission) => { + return userPermissions.some((userPermission) => matches(requiredPermission, userPermission)); + }); +}; + +const verifyPermission = (resourceActions: string[]) => { + return async (req: Request, res: Response, next: NextFunction) => { + const tokenizedUser = req.user; + + if (!tokenizedUser) { + throw new ApiError("No user", 400); + } + + const userId = tokenizedUser.sub; + if (!userId) { + throw new ApiError("No user ID", 400); + } + + const userRoles = await getCachedRoles(userId); + if (!userRoles) { + throw new ApiError("User roles not found", 400); + } + const allowed = hasPermission(userRoles, resourceActions); + if (!allowed) { + throw new ApiError("Insufficient permissions", 403); + } + next(); + }; +}; + +export { verifyPermission }; diff --git a/server/src/middleware/v2/VerifyToken.ts b/server/src/middleware/v2/VerifyToken.ts new file mode 100644 index 000000000..7dabd51cc --- /dev/null +++ b/server/src/middleware/v2/VerifyToken.ts @@ -0,0 +1,21 @@ +import { Request, Response, NextFunction } from "express"; +import { decode } from "../../utils/JWTUtils.js"; +import ApiError from "../../utils/ApiError.js"; +const verifyToken = (req: Request, res: Response, next: NextFunction) => { + const token = req.cookies.token; + + if (!token) { + const error = new ApiError("No token provided", 401); + return next(error); + } + + try { + const decoded = decode(token); + req.user = decoded; + next(); + } catch (error) { + next(error); + } +}; + +export { verifyToken }; diff --git a/server/src/routes/v2/auth.ts b/server/src/routes/v2/auth.ts index e69de29bb..919d0fd76 100644 --- a/server/src/routes/v2/auth.ts +++ b/server/src/routes/v2/auth.ts @@ -0,0 +1,33 @@ +import { Router } from "express"; + +import express from "express"; +import AuthController from "../../controllers/v2/AuthController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; + +const router = express.Router(); + +class AuthRoutes { + private controller: AuthController; + private router: Router; + constructor(authController: AuthController) { + this.controller = authController; + this.router = Router(); + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/register", this.controller.register); + this.router.post("/register/invite/:token", this.controller.registerWithInvite); + this.router.post("/login", this.controller.login); + this.router.post("/logout", this.controller.logout); + this.router.get("/me", verifyToken, this.controller.me); + this.router.post("/cleanup", this.controller.cleanup); + this.router.post("/cleanup-monitors", this.controller.cleanMonitors); + }; + + getRouter() { + return this.router; + } +} + +export default AuthRoutes; diff --git a/server/src/routes/v2/invite.ts b/server/src/routes/v2/invite.ts new file mode 100644 index 000000000..8066d0c77 --- /dev/null +++ b/server/src/routes/v2/invite.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import InviteController from "../../controllers/v2/InviteController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; +import { verifyPermission } from "../../middleware/v2/VerifyPermissions.js"; + +class InviteRoutes { + private router; + private controller; + constructor(inviteController: InviteController) { + this.router = Router(); + this.controller = inviteController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/", verifyToken, verifyPermission(["invite.create"]), this.controller.create); + + this.router.get("/", verifyToken, verifyPermission(["invite.view"]), this.controller.getAll); + + this.router.get("/:token", verifyToken, verifyPermission(["invite.view"]), this.controller.get); + + this.router.delete("/:id", verifyToken, verifyPermission(["invite.delete"]), this.controller.delete); + }; + + getRouter() { + return this.router; + } +} + +export default InviteRoutes; diff --git a/server/src/routes/v2/maintenance.ts b/server/src/routes/v2/maintenance.ts new file mode 100644 index 000000000..62bc5f1cf --- /dev/null +++ b/server/src/routes/v2/maintenance.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import MaintenanceController from "../../controllers/v2/MaintenanceController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; +import { verifyPermission } from "../../middleware/v2/VerifyPermissions.js"; + +class MaintenanceRoutes { + private router; + private controller; + constructor(maintenanceController: MaintenanceController) { + this.router = Router(); + this.controller = maintenanceController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/", verifyToken, verifyPermission(["maintenance.create"]), this.controller.create); + + this.router.get("/", verifyToken, verifyPermission(["maintenance.view"]), this.controller.getAll); + + this.router.patch("/:id/active", verifyToken, verifyPermission(["maintenance.update"]), this.controller.toggleActive); + + this.router.patch("/:id", verifyToken, verifyPermission(["maintenance.update"]), this.controller.update); + + this.router.get("/:id", verifyToken, verifyPermission(["maintenance.view"]), this.controller.get); + + this.router.delete("/:id", verifyToken, verifyPermission(["maintenance.delete"]), this.controller.delete); + }; + + getRouter() { + return this.router; + } +} + +export default MaintenanceRoutes; diff --git a/server/src/routes/v2/monitors.ts b/server/src/routes/v2/monitors.ts new file mode 100644 index 000000000..eb218f4c6 --- /dev/null +++ b/server/src/routes/v2/monitors.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import MonitorController from "../../controllers/v2/MonitorController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; +import { verifyPermission } from "../../middleware/v2/VerifyPermissions.js"; + +class MonitorRoutes { + private router; + private controller; + constructor(monitorController: MonitorController) { + this.router = Router(); + this.controller = monitorController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/", verifyToken, verifyPermission(["monitors.create"]), this.controller.create); + + this.router.get("/", verifyToken, verifyPermission(["monitors.view"]), this.controller.getAll); + + this.router.patch("/:id/active", verifyToken, verifyPermission(["monitors.update"]), this.controller.toggleActive); + + this.router.patch("/:id", verifyToken, verifyPermission(["monitors.update"]), this.controller.update); + + this.router.get("/:id", verifyToken, verifyPermission(["monitors.view"]), this.controller.get); + + this.router.delete("/:id", verifyToken, verifyPermission(["monitors.delete"]), this.controller.delete); + }; + + getRouter() { + return this.router; + } +} + +export default MonitorRoutes; diff --git a/server/src/routes/v2/notificationChannels.ts b/server/src/routes/v2/notificationChannels.ts new file mode 100644 index 000000000..1db32902a --- /dev/null +++ b/server/src/routes/v2/notificationChannels.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import NotificationController from "../../controllers/v2/NotificationChannelController.js"; +import { verifyToken } from "../../middleware/v2/VerifyToken.js"; +import { verifyPermission } from "../../middleware/v2/VerifyPermissions.js"; + +class NotificationChannelRoutes { + private router; + private controller; + constructor(notificationController: NotificationController) { + this.router = Router(); + this.controller = notificationController; + this.initRoutes(); + } + + initRoutes = () => { + this.router.post("/", verifyToken, verifyPermission(["notifications.create"]), this.controller.create); + + this.router.get("/", verifyToken, verifyPermission(["notifications.view"]), this.controller.getAll); + + this.router.patch("/:id/active", verifyToken, verifyPermission(["notifications.update"]), this.controller.toggleActive); + + this.router.patch("/:id", verifyToken, verifyPermission(["notifications.update"]), this.controller.update); + + this.router.get("/:id", verifyToken, verifyPermission(["notifications.view"]), this.controller.get); + + this.router.delete("/:id", verifyToken, verifyPermission(["notifications.delete"]), this.controller.delete); + }; + + getRouter() { + return this.router; + } +} + +export default NotificationChannelRoutes; diff --git a/server/src/routes/v2/queue.ts b/server/src/routes/v2/queue.ts new file mode 100644 index 000000000..428776e61 --- /dev/null +++ b/server/src/routes/v2/queue.ts @@ -0,0 +1,24 @@ +import QueueController from "../../controllers/v2/QueueController.js"; +import { Router } from "express"; + +class QueueRoutes { + private router; + private controller; + constructor(queueController: QueueController) { + this.router = Router(); + this.controller = queueController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/jobs", this.controller.getJobs); + this.router.get("/metrics", this.controller.getMetrics); + this.router.post("/flush", this.controller.flush); + } + + getRouter() { + return this.router; + } +} + +export default QueueRoutes; diff --git a/server/src/service/v2/business/AuthService.ts b/server/src/service/v2/business/AuthService.ts index cf49a71c6..67ece226e 100644 --- a/server/src/service/v2/business/AuthService.ts +++ b/server/src/service/v2/business/AuthService.ts @@ -4,6 +4,8 @@ import ApiError from "../../../utils/ApiError.js"; import { Types } from "mongoose"; import { IJobQueue } from "../infrastructure/JobQueue.js"; +const SERVICE_NAME = "AuthServiceV2"; + export const PERMISSIONS = { users: { all: "users.*", @@ -94,6 +96,8 @@ export interface IAuthService { } class AuthService implements IAuthService { + static SERVICE_NAME = SERVICE_NAME; + private jobQueue: IJobQueue; constructor(jobQueue: IJobQueue) { this.jobQueue = jobQueue; diff --git a/server/src/service/v2/business/CheckService.ts b/server/src/service/v2/business/CheckService.ts index 5c68891a9..7187fb5a3 100644 --- a/server/src/service/v2/business/CheckService.ts +++ b/server/src/service/v2/business/CheckService.ts @@ -6,12 +6,15 @@ import { StatusResponse } from "../infrastructure/NetworkService.js"; import type { ICapturePayload, ILighthousePayload } from "../infrastructure/NetworkService.js"; import mongoose from "mongoose"; +const SERVICE_NAME = "CheckServiceV2"; export interface ICheckService { buildCheck: (statusResponse: StatusResponse, type: MonitorType) => Promise; cleanupOrphanedChecks: () => Promise; } class CheckService implements ICheckService { + static SERVICE_NAME = SERVICE_NAME; + private isCapturePayload = (payload: any): payload is ICapturePayload => { if (!payload || typeof payload !== "object") return false; diff --git a/server/src/service/v2/business/InviteService.ts b/server/src/service/v2/business/InviteService.ts index 1c954d75f..b06b2a2c2 100644 --- a/server/src/service/v2/business/InviteService.ts +++ b/server/src/service/v2/business/InviteService.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import { ITokenizedUser, IInvite, Invite } from "../../../db/v2/models/index.js"; import ApiError from "../../../utils/ApiError.js"; +const SERVICE_NAME = "InviteServiceV2"; export interface IInviteService { create: (tokenizedUser: ITokenizedUser, invite: IInvite) => Promise<{ token: string }>; getAll: () => Promise; @@ -10,6 +11,7 @@ export interface IInviteService { } class InviteService implements IInviteService { + static SERVICE_NAME = SERVICE_NAME; constructor() {} create = async (tokenizedUser: ITokenizedUser, inviteData: IInvite) => { diff --git a/server/src/service/v2/business/MaintenanceService.ts b/server/src/service/v2/business/MaintenanceService.ts index ef8c8152f..55b2d787b 100644 --- a/server/src/service/v2/business/MaintenanceService.ts +++ b/server/src/service/v2/business/MaintenanceService.ts @@ -1,6 +1,8 @@ import { ITokenizedUser, IMaintenance, Maintenance } from "../../../db/v2/models/index.js"; import ApiError from "../../../utils/ApiError.js"; +const SERVICE_NAME = "MaintenanceServiceV2"; + export interface IMaintenanceService { create: ( tokenizedUser: ITokenizedUser, @@ -18,6 +20,7 @@ export interface IMaintenanceService { type MaintenanceCache = Map; class MaintenanceService implements IMaintenanceService { + static SERVICE_NAME = SERVICE_NAME; private maintenanceCache: MaintenanceCache; private lastRefresh: number; private CACHE_TTL_MS = 60 * 1000; diff --git a/server/src/service/v2/business/MonitorService.ts b/server/src/service/v2/business/MonitorService.ts new file mode 100644 index 000000000..61fc8b114 --- /dev/null +++ b/server/src/service/v2/business/MonitorService.ts @@ -0,0 +1,468 @@ +import mongoose from "mongoose"; + +import { IMonitor, Monitor, ITokenizedUser, MonitorStats, Check } from "../../../db/v2/models/index.js"; +import ApiError from "../../../utils/ApiError.js"; +import { IJobQueue } from "../infrastructure/JobQueue.js"; +import { MonitorWithChecksResponse } from "../../../types/index.js"; +import { MonitorStatus, MonitorType } from "../../../db/v2/models/monitors/Monitor.js"; + +const SERVICE_NAME = "MonitorServiceV2"; + +export interface IMonitorService { + create: (tokenizedUser: ITokenizedUser, monitorData: IMonitor) => Promise; + getAll: () => Promise; + getAllEmbedChecks: (page: number, limit: number, type: MonitorType[]) => Promise; + get: (monitorId: string) => Promise; + getEmbedChecks: (monitorId: string, range: string, status?: string) => Promise; + toggleActive: (monitorId: string, tokenizedUser: ITokenizedUser) => Promise; + update: (tokenizedUser: ITokenizedUser, monitorId: string, updateData: Partial) => Promise; + delete: (monitorId: string) => Promise; +} + +class MonitorService implements IMonitorService { + static SERVICE_NAME = SERVICE_NAME; + private jobQueue: IJobQueue; + constructor(jobQueue: IJobQueue) { + this.jobQueue = jobQueue; + } + + create = async (tokenizedUser: ITokenizedUser, monitorData: IMonitor) => { + const monitor = await Monitor.create({ + ...monitorData, + createdBy: tokenizedUser.sub, + updatedBy: tokenizedUser.sub, + }); + await MonitorStats.create({ + monitorId: monitor._id, + currentStreakStartedAt: Date.now(), + }); + await this.jobQueue.addJob(monitor); + return monitor; + }; + + getAll = async () => { + return Monitor.find(); + }; + + getAllEmbedChecks = async (page: number, limit: number, type: MonitorType[] = []) => { + const skip = (page - 1) * limit; + let find = {}; + if (type.length > 0) find = { type: { $in: type } }; + const monitors = await Monitor.find(find).skip(skip).limit(limit); + return monitors; + }; + + get = async (monitorId: string) => { + const monitor = await Monitor.findById(monitorId); + if (!monitor) { + throw new ApiError("Monitor not found", 404); + } + return monitor; + }; + + private getStartDate(range: string): Date { + const now = new Date(); + switch (range) { + case "30m": + return new Date(now.getTime() - 30 * 60 * 1000); + case "24h": + return new Date(now.getTime() - 24 * 60 * 60 * 1000); + case "7d": + return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + case "30d": + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + default: + throw new ApiError("Invalid range parameter", 400); + } + } + + private getDateFormat(range: string): string { + switch (range) { + case "30m": + return "%Y-%m-%dT%H:%M:00Z"; + case "24h": + case "7d": + return "%Y-%m-%dT%H:00:00Z"; + case "30d": + return "%Y-%m-%d"; + default: + throw new ApiError("Invalid range parameter", 400); + } + } + + private getBaseGroup = (dateFormat: string): Record => { + return { + _id: { $dateToString: { format: dateFormat, date: "$createdAt" } }, + count: { $sum: 1 }, + avgResponseTime: { $avg: "$responseTime" }, + }; + }; + + private getBaseProjection = (): object => { + return { status: 1, responseTime: 1, createdAt: 1 }; + }; + + private getPageSpeedGroup = (dateFormat: string): Record => { + return { + _id: { $dateToString: { format: dateFormat, date: "$createdAt" } }, + count: { $sum: 1 }, + avgResponseTime: { $avg: "$responseTime" }, + accessibility: { $avg: "$lighthouse.accessibility" }, + bestPractices: { $avg: "$lighthouse.bestPractices" }, + seo: { $avg: "$lighthouse.seo" }, + performance: { $avg: "$lighthouse.performance" }, + cls: { $avg: "$lighthouse.audits.cls.score" }, + si: { $avg: "$lighthouse.audits.si.score" }, + fcp: { $avg: "$lighthouse.audits.fcp.score" }, + lcp: { $avg: "$lighthouse.audits.lcp.score" }, + tbt: { $avg: "$lighthouse.audits.tbt.score" }, + }; + }; + + private getPageSpeedProjection = (): object => { + const projectStage: any = { status: 1, responseTime: 1, createdAt: 1 }; + projectStage["lighthouse.accessibility"] = 1; + projectStage["lighthouse.seo"] = 1; + projectStage["lighthouse.bestPractices"] = 1; + projectStage["lighthouse.performance"] = 1; + projectStage["lighthouse.audits.cls.score"] = 1; + projectStage["lighthouse.audits.si.score"] = 1; + projectStage["lighthouse.audits.fcp.score"] = 1; + projectStage["lighthouse.audits.lcp.score"] = 1; + projectStage["lighthouse.audits.tbt.score"] = 1; + return projectStage; + }; + + private getInfraGroup = (dateFormat: string): Record => { + return { + _id: { $dateToString: { format: dateFormat, date: "$createdAt" } }, + count: { $sum: 1 }, + avgResponseTime: { $avg: "$responseTime" }, + physicalCores: { $last: "$system.cpu.physical_core" }, + logicalCores: { $last: "$system.cpu.logical_core" }, + frequency: { $avg: "$system.cpu.frequency" }, + currentFrequency: { $last: "$system.cpu.current_frequency" }, + tempsArrays: { $push: "$system.cpu.temperature" }, + freePercent: { $avg: "$system.cpu.free_percent" }, + usedPercent: { $avg: "$system.cpu.usage_percent" }, + total_bytes: { $last: "$system.memory.total_bytes" }, + available_bytes: { $last: "$system.memory.available_bytes" }, + used_bytes: { $last: "$system.memory.used_bytes" }, + memory_usage_percent: { $avg: "$system.memory.usage_percent" }, + disksArray: { $push: "$system.disk" }, + os: { $last: "$system.host.os" }, + platform: { $last: "$system.host.platform" }, + kernel_version: { $last: "$system.host.kernel_version" }, + pretty_name: { $last: "$system.host.pretty_name" }, + netsArray: { $push: "$system.net" }, + }; + }; + + private getInfraProjection = (): object => { + const projectStage: any = { status: 1, responseTime: 1, createdAt: 1 }; + projectStage["system.cpu.physical_core"] = 1; + projectStage["system.cpu.logical_core"] = 1; + projectStage["system.cpu.frequency"] = 1; + projectStage["system.cpu.current_frequency"] = 1; + projectStage["system.cpu.temperature"] = 1; + projectStage["system.cpu.free_percent"] = 1; + projectStage["system.cpu.usage_percent"] = 1; + projectStage["system.memory.total_bytes"] = 1; + projectStage["system.memory.available_bytes"] = 1; + projectStage["system.memory.used_bytes"] = 1; + projectStage["system.memory.usage_percent"] = 1; + projectStage["system.disk"] = 1; + projectStage["system.host.os"] = 1; + projectStage["system.host.platform"] = 1; + projectStage["system.host.kernel_version"] = 1; + projectStage["system.host.pretty_name"] = 1; + projectStage["system.net"] = 1; + return projectStage; + }; + + private getFinalProjection = (type: string): object => { + if (type === "pagespeed") { + return { + _id: 1, + count: 1, + avgResponseTime: 1, + accessibility: "$accessibility", + seo: "$seo", + bestPractices: "$bestPractices", + performance: "$performance", + cls: "$cls", + si: "$si", + fcp: "$fcp", + lcp: "$lcp", + tbt: "$tbt", + }; + } + + if (type === "infrastructure") { + return { + _id: 1, + count: 1, + avgResponseTime: 1, + cpu: { + physicalCores: "$physicalCores", + logicalCores: "$logicalCores", + frequency: "$frequency", + currentFrequency: "$currentFrequency", + temperatures: { + $map: { + input: { + $range: [0, { $size: { $arrayElemAt: ["$tempsArrays", 0] } }], + }, + as: "idx", + in: { + $avg: { + $map: { + input: "$tempsArrays", + as: "arr", + in: { $arrayElemAt: ["$$arr", "$$idx"] }, + }, + }, + }, + }, + }, + freePercent: "$freePercent", + usedPercent: "$usedPercent", + }, + memory: { + total_bytes: "$total_bytes", + available_bytes: "$available_bytes", + used_bytes: "$used_bytes", + usage_percent: "$memory_usage_percent", + }, + disks: { + $map: { + input: { + $range: [0, { $size: { $arrayElemAt: ["$disksArray", 0] } }], + }, + as: "idx", + in: { + $let: { + vars: { + diskGroup: { + $map: { + input: "$disksArray", + as: "diskArr", + in: { $arrayElemAt: ["$$diskArr", "$$idx"] }, + }, + }, + }, + in: { + device: { $arrayElemAt: ["$$diskGroup.device", 0] }, + total_bytes: { $avg: "$$diskGroup.total_bytes" }, + free_bytes: { $avg: "$$diskGroup.free_bytes" }, + used_bytes: { $avg: "$$diskGroup.used_bytes" }, + usage_percent: { $avg: "$$diskGroup.usage_percent" }, + total_inodes: { $avg: "$$diskGroup.total_inodes" }, + free_inodes: { $avg: "$$diskGroup.free_inodes" }, + used_inodes: { $avg: "$$diskGroup.used_inodes" }, + inodes_usage_percent: { + $avg: "$$diskGroup.inodes_usage_percent", + }, + read_bytes: { $avg: "$$diskGroup.read_bytes" }, + write_bytes: { $avg: "$$diskGroup.write_bytes" }, + read_time: { $avg: "$$diskGroup.read_time" }, + write_time: { $avg: "$$diskGroup.write_time" }, + }, + }, + }, + }, + }, + host: { + os: "$os", + platform: "$platform", + kernel_version: "$kernel_version", + pretty_name: "$pretty_name", + }, + net: { + $map: { + input: { + $range: [0, { $size: { $arrayElemAt: ["$netsArray", 0] } }], + }, + as: "idx", + in: { + $let: { + vars: { + netGroup: { + $map: { + input: "$netsArray", + as: "netArr", + in: { $arrayElemAt: ["$$netArr", "$$idx"] }, + }, + }, + }, + in: { + name: { $arrayElemAt: ["$$netGroup.name", 0] }, + bytes_sent: { $avg: "$$netGroup.bytes_sent" }, + bytes_recv: { $avg: "$$netGroup.bytes_recv" }, + packets_sent: { $avg: "$$netGroup.packets_sent" }, + packets_recv: { $avg: "$$netGroup.packets_recv" }, + err_in: { $avg: "$$netGroup.err_in" }, + err_out: { $avg: "$$netGroup.err_out" }, + drop_in: { $avg: "$$netGroup.drop_in" }, + drop_out: { $avg: "$$netGroup.drop_out" }, + fifo_in: { $avg: "$$netGroup.fifo_in" }, + fifo_out: { $avg: "$$netGroup.fifo_out" }, + }, + }, + }, + }, + }, + }; + } + return {}; + }; + + getEmbedChecks = async (monitorId: string, range: string, status: string | undefined): Promise => { + const monitor = await Monitor.findById(monitorId); + if (!monitor) { + throw new ApiError("Monitor not found", 404); + } + const startDate = this.getStartDate(range); + const dateFormat = this.getDateFormat(range); + + // Build match stage + const matchStage: { + monitorId: mongoose.Types.ObjectId; + createdAt: { $gte: Date }; + status?: string; + } = { + monitorId: monitor._id, + createdAt: { $gte: startDate }, + }; + + if (status) { + matchStage.status = status; + } + + let groupClause; + + if (monitor.type === "pagespeed") { + groupClause = this.getPageSpeedGroup(dateFormat); + } else if (monitor.type === "infrastructure") { + groupClause = this.getInfraGroup(dateFormat); + } else { + groupClause = this.getBaseGroup(dateFormat); + } + + let projectStage; + if (monitor.type === "pagespeed") { + projectStage = this.getPageSpeedProjection(); + } else if (monitor.type === "infrastructure") { + projectStage = this.getInfraProjection(); + } else { + projectStage = this.getBaseProjection(); + } + + let finalProjection = {}; + if (monitor.type === "pagespeed" || monitor.type === "infrastructure") { + finalProjection = this.getFinalProjection(monitor.type); + } else { + finalProjection = { _id: 1, count: 1, avgResponseTime: 1 }; + } + + const checks = await Check.aggregate([ + { + $match: matchStage, + }, + { $sort: { createdAt: 1 } }, + { $project: projectStage }, + { $group: groupClause }, + { $sort: { _id: -1 } }, + { + $project: finalProjection, + }, + ]); + + // Get monitor stats + const monitorStats = await MonitorStats.findOne({ + monitorId: monitor._id, + }).lean(); + + if (!monitorStats) { + throw new ApiError("Monitor stats not found", 404); + } + + return { + monitor: monitor.toObject(), + checks, + stats: monitorStats, + }; + }; + + async toggleActive(id: string, tokenizedUser: ITokenizedUser) { + const pendingStatus: MonitorStatus = "initializing"; + const updatedMonitor = await Monitor.findOneAndUpdate( + { _id: id }, + [ + { + $set: { + isActive: { $not: "$isActive" }, + status: pendingStatus, + updatedBy: tokenizedUser.sub, + updatedAt: new Date(), + }, + }, + ], + { new: true } + ); + + if (!updatedMonitor) { + throw new ApiError("Monitor not found", 404); + } + + await this.jobQueue.updateJob(updatedMonitor); + + if (updatedMonitor?.isActive) { + await this.jobQueue.resumeJob(updatedMonitor); + } else { + await this.jobQueue.pauseJob(updatedMonitor); + } + return updatedMonitor; + } + + async update(tokenizedUser: ITokenizedUser, monitorId: string, updateData: Partial) { + const allowedFields: (keyof IMonitor)[] = ["name", "interval", "isActive", "n", "notificationChannels"]; + const safeUpdate: Partial = {}; + + for (const field of allowedFields) { + if (updateData[field] !== undefined) { + (safeUpdate as any)[field] = updateData[field]; + } + } + + const updatedMonitor = await Monitor.findByIdAndUpdate( + monitorId, + { + $set: { + ...safeUpdate, + updatedAt: new Date(), + updatedBy: tokenizedUser.sub, + }, + }, + { new: true, runValidators: true } + ); + + if (!updatedMonitor) { + throw new ApiError("Monitor not found", 404); + } + await this.jobQueue.updateJob(updatedMonitor); + return updatedMonitor; + } + + async delete(monitorId: string) { + const monitor = await Monitor.findById(monitorId); + if (!monitor) { + throw new ApiError("Monitor not found", 404); + } + await monitor.deleteOne(); + await this.jobQueue.deleteJob(monitor); + return true; + } +} + +export default MonitorService; diff --git a/server/src/service/v2/business/MonitorStatsService.ts b/server/src/service/v2/business/MonitorStatsService.ts index dc7d4acb3..089b43ec4 100644 --- a/server/src/service/v2/business/MonitorStatsService.ts +++ b/server/src/service/v2/business/MonitorStatsService.ts @@ -1,10 +1,13 @@ import { MonitorStats } from "../../../db/v2/models/index.js"; import { Monitor } from "../../../db/v2/models/index.js"; + +const SERVICE_NAME = "MonitorStatsServiceV2"; export interface IMonitorStatsService { cleanupOrphanedMonitorStats: () => Promise; } class MonitorStatsService implements IMonitorStatsService { + static SERVICE_NAME = SERVICE_NAME; constructor() {} async cleanupOrphanedMonitorStats() { diff --git a/server/src/service/v2/business/NotificationChannelService.ts b/server/src/service/v2/business/NotificationChannelService.ts index 35eae5b12..112efa691 100644 --- a/server/src/service/v2/business/NotificationChannelService.ts +++ b/server/src/service/v2/business/NotificationChannelService.ts @@ -1,6 +1,8 @@ import { ITokenizedUser, INotificationChannel, NotificationChannel, Monitor } from "../../../db/v2/models/index.js"; import ApiError from "../../../utils/ApiError.js"; +const SERVICE_NAME = "NotificationChannelServiceV2"; + export interface INotificationChannelService { create: ( tokenizedUser: ITokenizedUser, @@ -15,6 +17,8 @@ export interface INotificationChannelService { } class NotificationChannelService implements INotificationChannelService { + static SERVICE_NAME = SERVICE_NAME; + constructor() {} create = async (tokenizedUser: ITokenizedUser, notificationChannelData: INotificationChannel) => { diff --git a/server/src/service/v2/business/QueueService.ts b/server/src/service/v2/business/QueueService.ts index 20069072e..620544ba7 100644 --- a/server/src/service/v2/business/QueueService.ts +++ b/server/src/service/v2/business/QueueService.ts @@ -1,6 +1,9 @@ import { IJobQueue } from "../infrastructure/JobQueue.js"; +const SERVICE_NAME = "QueueServiceV2"; + class QueueService { + static SERVICE_NAME = SERVICE_NAME; private jobQueue: IJobQueue; constructor(jobQueue: IJobQueue) { diff --git a/server/src/service/v2/business/UserService.ts b/server/src/service/v2/business/UserService.ts index 46d221759..09c50dcbc 100644 --- a/server/src/service/v2/business/UserService.ts +++ b/server/src/service/v2/business/UserService.ts @@ -1,10 +1,12 @@ import { IUser, User } from "../../../db/v2/models/index.js"; +const SERVICE_NAME = "UserServiceV2"; export interface IUserService { getAllUsers(): Promise; } class UserService implements IUserService { + static SERVICE_NAME = SERVICE_NAME; async getAllUsers(): Promise { return await User.find(); } diff --git a/server/src/service/v2/infrastructure/JobGenerator.ts b/server/src/service/v2/infrastructure/JobGenerator.ts index 4f2beaf71..69b716920 100644 --- a/server/src/service/v2/infrastructure/JobGenerator.ts +++ b/server/src/service/v2/infrastructure/JobGenerator.ts @@ -7,12 +7,14 @@ import { INotificationService } from "./NotificationService.js"; import { IMaintenanceService } from "../business/MaintenanceService.js"; import ApiError from "../../../utils/ApiError.js"; +const SERVICE_NAME = "JobGeneratorV2"; export interface IJobGenerator { generateJob: () => (Monitor: IMonitor) => Promise; generateCleanupJob: () => () => Promise; } class JobGenerator implements IJobGenerator { + static SERVICE_NAME = SERVICE_NAME; private networkService: INetworkService; private checkService: ICheckService; private monitorStatsService: IMonitorStatsService; diff --git a/server/src/service/v2/infrastructure/JobQueue.ts b/server/src/service/v2/infrastructure/JobQueue.ts index af415d3f7..85479d8e5 100644 --- a/server/src/service/v2/infrastructure/JobQueue.ts +++ b/server/src/service/v2/infrastructure/JobQueue.ts @@ -2,6 +2,8 @@ import { IJob } from "super-simple-scheduler/dist/job/job.js"; import { Monitor, IMonitor } from "../../../db/v2/models/index.js"; import Scheduler from "super-simple-scheduler"; import { IJobGenerator } from "./JobGenerator.js"; + +const SERVICE_NAME = "JobQueueV2"; export interface IJobMetrics { jobs: number; activeJobs: number; @@ -36,6 +38,8 @@ export interface IJobQueue { } export default class JobQueue implements IJobQueue { + static SERVICE_NAME = SERVICE_NAME; + private scheduler: Scheduler; private static instance: JobQueue | null = null; private jobGenerator: any; diff --git a/server/src/service/v2/infrastructure/NetworkService.ts b/server/src/service/v2/infrastructure/NetworkService.ts index 90657bd2d..ab8427d84 100644 --- a/server/src/service/v2/infrastructure/NetworkService.ts +++ b/server/src/service/v2/infrastructure/NetworkService.ts @@ -1,4 +1,5 @@ import { Got, HTTPError } from "got"; +import got from "got"; import ping from "ping"; import { IMonitor } from "../../../db/v2/models/index.js"; import { GotTimings } from "../../../db/v2/models/checks/Check.js"; @@ -7,6 +8,8 @@ import type { ISystemInfo, ICaptureInfo, ILighthouseResult } from "../../../db/v import { MonitorType, MonitorStatus } from "../../../db/v2/models/monitors/Monitor.js"; import ApiError from "../../../utils/ApiError.js"; import { config } from "../../../config/index.js"; + +const SERVICE_NAME = "NetworkServiceV2"; export interface INetworkService { requestHttp: (monitor: IMonitor) => Promise; requestInfrastructure: (monitor: IMonitor) => Promise; @@ -36,9 +39,10 @@ export interface StatusResponse { } class NetworkService implements INetworkService { + static SERVICE_NAME = SERVICE_NAME; private got: Got; private NETWORK_ERROR: number; - constructor(got: Got) { + constructor() { this.got = got; this.NETWORK_ERROR = 5000; } diff --git a/server/src/service/v2/infrastructure/NotificationService.ts b/server/src/service/v2/infrastructure/NotificationService.ts index 4195958bd..2cc5e75d8 100644 --- a/server/src/service/v2/infrastructure/NotificationService.ts +++ b/server/src/service/v2/infrastructure/NotificationService.ts @@ -1,11 +1,14 @@ import UserService from "../business/UserService.js"; import { IMonitor, NotificationChannel } from "../../../db/v2/models/index.js"; import { EmailService, SlackService, DiscordService, WebhookService } from "./NotificationServices/index.js"; + +const SERVICE_NAME = "NotificationServiceV2"; export interface INotificationService { handleNotifications: (monitor: IMonitor) => Promise; } class NotificationService implements INotificationService { + static SERVICE_NAME = SERVICE_NAME; private emailService: EmailService; private slackService: SlackService; private discordService: DiscordService; diff --git a/server/src/service/v2/infrastructure/NotificationServices/Discord.ts b/server/src/service/v2/infrastructure/NotificationServices/Discord.ts index c9ec30700..c3a5d375a 100644 --- a/server/src/service/v2/infrastructure/NotificationServices/Discord.ts +++ b/server/src/service/v2/infrastructure/NotificationServices/Discord.ts @@ -2,7 +2,10 @@ import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.j import { IAlert, IMessageService } from "./IMessageService.js"; import got from "got"; import ApiError from "../../../../utils/ApiError.js"; + +const SERVICE_NAME = "DiscordServiceV2"; class DiscordService implements IMessageService { + static SERVICE_NAME = SERVICE_NAME; constructor() {} private toDiscordEmbeds = (alert: IAlert) => { diff --git a/server/src/service/v2/infrastructure/NotificationServices/Email.ts b/server/src/service/v2/infrastructure/NotificationServices/Email.ts index d1443ce08..4bed9a156 100644 --- a/server/src/service/v2/infrastructure/NotificationServices/Email.ts +++ b/server/src/service/v2/infrastructure/NotificationServices/Email.ts @@ -4,7 +4,10 @@ import nodemailer, { Transporter } from "nodemailer"; import { config } from "../../../../config/index.js"; import UserService from "../../business/UserService.js"; import ApiError from "../../../../utils/ApiError.js"; + +const SERVICE_NAME = "EmailServiceV2"; class EmailService implements IMessageService { + static SERVICE_NAME = SERVICE_NAME; private transporter: Transporter; private userService: UserService; diff --git a/server/src/service/v2/infrastructure/NotificationServices/Slack.ts b/server/src/service/v2/infrastructure/NotificationServices/Slack.ts index 50e2021e4..0be56ca56 100644 --- a/server/src/service/v2/infrastructure/NotificationServices/Slack.ts +++ b/server/src/service/v2/infrastructure/NotificationServices/Slack.ts @@ -2,7 +2,9 @@ import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.j import { IAlert, IMessageService } from "./IMessageService.js"; import got from "got"; +const SERVICE_NAME = "SlackServiceV2"; class SlackService implements IMessageService { + static SERVICE_NAME = SERVICE_NAME; constructor() {} private toSlackBlocks = (alert: IAlert) => { diff --git a/server/src/service/v2/infrastructure/NotificationServices/Webhook.ts b/server/src/service/v2/infrastructure/NotificationServices/Webhook.ts index b84b2b220..5dd89b16d 100644 --- a/server/src/service/v2/infrastructure/NotificationServices/Webhook.ts +++ b/server/src/service/v2/infrastructure/NotificationServices/Webhook.ts @@ -2,7 +2,11 @@ import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.j import { IAlert, IMessageService } from "./IMessageService.js"; import ApiError from "../../../../utils/ApiError.js"; import got from "got"; + +const SERVICE_NAME = "WebhookServiceV2"; class WebhookService implements IMessageService { + static SERVICE_NAME = SERVICE_NAME; + constructor() {} buildAlert = (monitor: IMonitor) => { diff --git a/server/src/service/v2/infrastructure/StatusService.ts b/server/src/service/v2/infrastructure/StatusService.ts index 87b0df0af..f2881b160 100644 --- a/server/src/service/v2/infrastructure/StatusService.ts +++ b/server/src/service/v2/infrastructure/StatusService.ts @@ -2,6 +2,7 @@ import { IMonitor, IMonitorStats, MonitorStats } from "../../../db/v2/models/ind import { StatusResponse } from "./NetworkService.js"; import ApiError from "../../../utils/ApiError.js"; +const SERVICE_NAME = "StatusServiceV2"; const MAX_LATEST_CHECKS = 25; export interface IStatusService { updateMonitorStatus: (monitor: IMonitor, status: StatusResponse) => Promise; @@ -14,6 +15,7 @@ export interface IStatusService { export type StatusChangeResult = [updatedMonitor: IMonitor, statusChanged: boolean]; class StatusService implements IStatusService { + static SERVICE_NAME = SERVICE_NAME; updateMonitorStatus = async (monitor: IMonitor, statusResponse: StatusResponse): Promise => { const newStatus = statusResponse.status; monitor.lastCheckedAt = new Date(); diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts new file mode 100644 index 000000000..7c78a053c --- /dev/null +++ b/server/src/types/express.d.ts @@ -0,0 +1,10 @@ +import { ITokenizedUser } from "../db/models/index.ts"; + +declare global { + namespace Express { + interface Request { + user?: ITokenizedUser; + resource?: any; + } + } +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts new file mode 100644 index 000000000..6e708b934 --- /dev/null +++ b/server/src/types/index.ts @@ -0,0 +1 @@ +export type { MonitorWithChecksResponse } from "./monitor-response-with-checks.js"; diff --git a/server/src/types/monitor-response-with-checks.ts b/server/src/types/monitor-response-with-checks.ts new file mode 100644 index 000000000..660ce0035 --- /dev/null +++ b/server/src/types/monitor-response-with-checks.ts @@ -0,0 +1,11 @@ +import { IMonitor, IMonitorStats } from "../db/v2/models/index.js"; + +export interface MonitorWithChecksResponse { + monitor: IMonitor; + checks: Array<{ + _id: string; + count: number; + avgResponseTime: number; + }>; + stats: IMonitorStats; +}