Merge pull request #2977 from bluewave-labs/feat/v2/services

feat/v2/services
This commit is contained in:
Alexander Holliday
2025-09-24 14:21:28 -07:00
committed by GitHub
25 changed files with 3152 additions and 0 deletions

1411
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,9 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/jsonwebtoken": "9.0.10",
"@types/nodemailer": "7.0.1",
"@types/ping": "0.4.4",
"c8": "10.1.3",
"chai": "5.2.0",
"eslint": "^9.17.0",

View File

@@ -0,0 +1,25 @@
import dotenv from "dotenv";
export interface IEevConfig {
NODE_ENV: string;
JWT_SECRET: string;
PORT: number;
PAGESPEED_API_KEY: string;
SMTP_HOST: string;
SMTP_PORT: number;
SMTP_USER: string;
SMTP_PASS: string;
}
dotenv.config();
export const config: IEevConfig = {
NODE_ENV: process.env.NODE_ENV || "development",
JWT_SECRET: process.env.JWT_SECRET || "your_jwt_secret",
PORT: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
PAGESPEED_API_KEY: process.env.PAGESPEED_API_KEY || "",
SMTP_HOST: process.env.SMTP_HOST || "smtp.example.com",
SMTP_PORT: process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT, 10) : 587,
SMTP_USER: process.env.SMTP_USER || "user@example.com",
SMTP_PASS: process.env.SMTP_PASS || "your_smtp_password",
};

View File

View File

@@ -0,0 +1,206 @@
import bcrypt from "bcryptjs";
import { User, Role, ITokenizedUser, Monitor, Check, NotificationChannel } from "../../../db/v2/models/index.js";
import ApiError from "../../../utils/ApiError.js";
import { Types } from "mongoose";
import { IJobQueue } from "../infrastructure/JobQueue.js";
export const PERMISSIONS = {
users: {
all: "users.*",
create: "users.create",
view: "users.view",
update: "users.update",
delete: "users.delete",
},
monitors: {
all: "monitors.*",
create: "monitors.create",
view: "monitors.view",
update: "monitors.update",
delete: "monitors.delete",
},
notifications: {
all: "notifications.*",
create: "notifications.create",
view: "notifications.view",
update: "notifications.update",
delete: "notifications.delete",
},
checks: {
all: "checks.*",
create: "checks.create",
view: "checks.view",
update: "checks.update",
delete: "checks.delete",
},
statusPages: {
all: "statusPages.*",
create: "statusPages.create",
view: "statusPages.view",
update: "statusPages.update",
delete: "statusPages.delete",
},
};
const DEFAULT_ROLES = [
{
name: "SuperAdmin",
description: "Super admin with all permissions",
permissions: ["*"],
isSystem: true,
},
{
name: "Admin",
description: "Admin with full permissions",
permissions: [PERMISSIONS.monitors.all, PERMISSIONS.users.all],
isSystem: true,
},
{
name: "Manager",
description: "Can manage users",
permissions: [PERMISSIONS.users.create, PERMISSIONS.users.update, PERMISSIONS.monitors.all],
isSystem: true,
},
{
name: "Member",
description: "Basic team member",
permissions: [PERMISSIONS.users.update, PERMISSIONS.monitors.create, PERMISSIONS.monitors.view, PERMISSIONS.monitors.update],
isSystem: true,
},
];
export type RegisterData = {
email: string;
firstName: string;
lastName: string;
password: string;
roles?: Types.ObjectId[]; // Optional roles for invite-based registration
};
export type LoginData = {
email: string;
password: string;
};
export type AuthResult = ITokenizedUser;
export interface IAuthService {
register(signupData: RegisterData): Promise<ITokenizedUser>;
registerWithInvite(signupData: RegisterData): Promise<ITokenizedUser>;
login(loginData: LoginData): Promise<ITokenizedUser>;
cleanup(): Promise<void>;
cleanMonitors(): Promise<void>;
}
class AuthService implements IAuthService {
private jobQueue: IJobQueue;
constructor(jobQueue: IJobQueue) {
this.jobQueue = jobQueue;
}
async register(signupData: RegisterData): Promise<ITokenizedUser> {
const userCount = await User.countDocuments();
if (userCount > 0) {
throw new Error("Registration is closed. Please request an invite.");
}
const { email, firstName, lastName, password } = signupData;
// Create all default roles
const rolePromises = DEFAULT_ROLES.map((roleData) =>
new Role({
...roleData,
}).save()
);
const roles = await Promise.all(rolePromises);
// Hash password and create user
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Find admin role and assign to first user
const superAdminRole = roles.find((role) => role.name === "SuperAdmin");
const user = new User({
email,
firstName,
lastName,
passwordHash,
roles: [superAdminRole!._id],
});
const savedUser = await user.save();
return {
sub: savedUser._id.toString(),
roles: savedUser.roles.map((role) => role.toString()),
};
}
async registerWithInvite(signupData: RegisterData): Promise<ITokenizedUser> {
const { email, firstName, lastName, password, roles } = signupData;
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
const user = new User({
email,
firstName,
lastName,
passwordHash,
roles: roles || [],
});
try {
const savedUser = await user.save();
return {
sub: savedUser._id.toString(),
roles: savedUser.roles.map((role) => role.toString()),
};
} catch (error: any) {
if (error?.code === 11000) {
const dupError = new ApiError("Email already in use", 409);
dupError.stack = error?.stack;
throw dupError;
}
throw error;
}
}
async login(loginData: LoginData): Promise<ITokenizedUser> {
const { email, password } = loginData;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
throw new Error("Invalid email or password");
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new Error("Invalid email or password");
}
return {
sub: user._id.toString(),
roles: user.roles.map((role) => role.toString()),
};
}
async cleanup() {
await User.deleteMany({});
await Role.deleteMany({});
await Monitor.deleteMany({});
await Check.deleteMany({});
await NotificationChannel.deleteMany({});
await this.jobQueue.flush();
}
async cleanMonitors() {
await Monitor.deleteMany({});
await Check.deleteMany({});
}
}
export default AuthService;

View File

@@ -0,0 +1,132 @@
import { json } from "stream/consumers";
import { ICheck, Check, Monitor } from "../../../db/v2/models/index.js";
import type { ISystemInfo, ICaptureInfo } from "../../../db/v2/models/index.js";
import { MonitorType } from "../../../db/v2/models/monitors/Monitor.js";
import { StatusResponse } from "../infrastructure/NetworkService.js";
import type { ICapturePayload, ILighthousePayload } from "../infrastructure/NetworkService.js";
import mongoose from "mongoose";
export interface ICheckService {
buildCheck: (statusResponse: StatusResponse, type: MonitorType) => Promise<ICheck>;
cleanupOrphanedChecks: () => Promise<boolean>;
}
class CheckService implements ICheckService {
private isCapturePayload = (payload: any): payload is ICapturePayload => {
if (!payload || typeof payload !== "object") return false;
if (!("data" in payload) || typeof payload.data !== "object") {
return false;
}
const data = payload.data as Partial<ISystemInfo>;
if (!data.cpu || typeof data.cpu !== "object" || typeof data.cpu.usage_percent !== "number") {
return false;
}
if (!data.memory || typeof data.memory !== "object" || typeof data.memory.usage_percent !== "number") {
return false;
}
if (data.disk && !Array.isArray(data.disk)) {
return false;
}
if (data.net && !Array.isArray(data.net)) {
return false;
}
if (!("capture" in payload) || typeof payload.capture !== "object") return false;
const capture = payload.capture as Record<string, any>;
if (typeof capture.version !== "string" || typeof capture.mode !== "string") return false;
return true;
};
private isPagespeedPayload = (payload: any): payload is ILighthousePayload => {
if (!payload || typeof payload !== "object") return false;
if (!("lighthouseResult" in payload) || typeof payload.lighthouseResult !== "object") {
return false;
}
return true;
};
private buildBaseCheck = (statusResponse: StatusResponse) => {
const monitorId = new mongoose.Types.ObjectId(statusResponse.monitorId);
const check = new Check({
monitorId: monitorId,
type: statusResponse?.type,
status: statusResponse?.status,
message: statusResponse?.message,
responseTime: statusResponse?.responseTime,
timings: statusResponse?.timings,
});
return check;
};
private buildInfrastructureCheck = (statusResponse: StatusResponse<ICapturePayload>) => {
if (!this.isCapturePayload(statusResponse.payload)) {
throw new Error("Invalid payload for infrastructure monitor");
}
const check = this.buildBaseCheck(statusResponse);
check.system = statusResponse.payload.data;
check.capture = statusResponse.payload.capture;
return check;
};
private buildPagespeedCheck = (statusResponse: StatusResponse<ILighthousePayload>) => {
if (!this.isPagespeedPayload(statusResponse.payload)) {
throw new Error("Invalid payload for pagespeed monitor");
}
const check = this.buildBaseCheck(statusResponse);
const lighthouseResult = statusResponse?.payload?.lighthouseResult;
check.lighthouse = {
accessibility: lighthouseResult?.categories?.accessibility?.score || 0,
bestPractices: lighthouseResult?.categories?.["best-practices"]?.score || 0,
seo: lighthouseResult?.categories?.seo?.score || 0,
performance: lighthouseResult?.categories?.performance?.score || 0,
audits: {
cls: lighthouseResult?.audits?.["cumulative-layout-shift"] || {},
si: lighthouseResult?.audits?.["speed-index"] || {},
fcp: lighthouseResult?.audits?.["first-contentful-paint"] || {},
lcp: lighthouseResult?.audits?.["largest-contentful-paint"] || {},
tbt: lighthouseResult?.audits?.["total-blocking-time"] || {},
},
};
return check;
};
buildCheck = async (statusResponse: StatusResponse, type: MonitorType): Promise<ICheck> => {
switch (type) {
case "infrastructure":
return this.buildInfrastructureCheck(statusResponse as StatusResponse<ICapturePayload>);
case "pagespeed":
return this.buildPagespeedCheck(statusResponse as StatusResponse<ILighthousePayload>);
case "http":
case "https":
return this.buildBaseCheck(statusResponse);
case "ping":
return this.buildBaseCheck(statusResponse);
default:
throw new Error(`Unsupported monitor type: ${type}`);
}
};
cleanupOrphanedChecks = async () => {
try {
const monitorIds = await Monitor.find().distinct("_id");
const result = await Check.deleteMany({
monitorId: { $nin: monitorIds },
});
console.log(`Deleted ${result.deletedCount} orphaned Checks.`);
return true;
} catch (error) {
console.error("Error cleaning up orphaned Checks:", error);
return false;
}
};
}
export default CheckService;

View File

@@ -0,0 +1,61 @@
import crypto from "node:crypto";
import { ITokenizedUser, IInvite, Invite } from "../../../db/v2/models/index.js";
import ApiError from "../../../utils/ApiError.js";
export interface IInviteService {
create: (tokenizedUser: ITokenizedUser, invite: IInvite) => Promise<{ token: string }>;
getAll: () => Promise<IInvite[]>;
get: (tokenHash: string) => Promise<IInvite>;
delete: (id: string) => Promise<boolean>;
}
class InviteService implements IInviteService {
constructor() {}
create = async (tokenizedUser: ITokenizedUser, inviteData: IInvite) => {
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
try {
const invite = await Invite.create({
...inviteData,
tokenHash,
createdBy: tokenizedUser.sub,
updatedBy: tokenizedUser.sub,
});
if (!invite) {
throw new ApiError("Failed to create invite", 500);
}
return { token };
} catch (error: any) {
if (error?.code === 11000) {
const dupError = new ApiError("Invite with this email already exists", 409);
dupError.stack = error?.stack;
throw dupError;
}
throw error;
}
};
get = async (token: string) => {
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const invite = await Invite.findOne({ tokenHash });
if (!invite) {
throw new ApiError("Invite not found", 404);
}
return invite;
};
getAll = async () => {
return Invite.find();
};
delete = async (id: string) => {
const result = await Invite.deleteOne({ _id: id });
if (!result.deletedCount) {
throw new ApiError("Invite not found", 404);
}
return result.deletedCount === 1;
};
}
export default InviteService;

View File

@@ -0,0 +1,141 @@
import { ITokenizedUser, IMaintenance, Maintenance } from "../../../db/v2/models/index.js";
import ApiError from "../../../utils/ApiError.js";
export interface IMaintenanceService {
create: (
tokenizedUser: ITokenizedUser,
maintenance: IMaintenance
) => Promise<IMaintenance>;
getAll: () => Promise<IMaintenance[]>;
get: (id: string) => Promise<IMaintenance>;
toggleActive: (tokenizedUser: ITokenizedUser, id: string) => Promise<IMaintenance>;
update: (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<IMaintenance>) => Promise<IMaintenance>;
delete: (id: string) => Promise<boolean>;
isInMaintenance: (monitorId: string) => Promise<boolean>;
}
type MaintenanceCache = Map<string, IMaintenance[]>;
class MaintenanceService implements IMaintenanceService {
private maintenanceCache: MaintenanceCache;
private lastRefresh: number;
private CACHE_TTL_MS = 60 * 1000;
constructor() {
this.maintenanceCache = new Map();
this.lastRefresh = 0;
}
create = async (tokenizedUser: ITokenizedUser, maintenanceData: IMaintenance) => {
const maintenance = await Maintenance.create({
...maintenanceData,
createdBy: tokenizedUser.sub,
updatedBy: tokenizedUser.sub,
});
return maintenance;
};
get = async (id: string) => {
const maintenance = await Maintenance.findById(id);
if (!maintenance) {
throw new ApiError("Maintenance not found", 404);
}
return maintenance;
};
getAll = async () => {
return Maintenance.find();
};
toggleActive = async (tokenizedUser: ITokenizedUser, id: string) => {
const updatedMaintenance = await Maintenance.findOneAndUpdate(
{ _id: id },
[
{
$set: {
isActive: { $not: "$isActive" },
updatedBy: tokenizedUser.sub,
updatedAt: new Date(),
},
},
],
{ new: true }
);
if (!updatedMaintenance) {
throw new ApiError("Maintenance not found", 404);
}
return updatedMaintenance;
};
update = async (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<IMaintenance>) => {
const allowedFields: (keyof IMaintenance)[] = ["name", "monitors", "startTime", "endTime", "isActive"];
const safeUpdate: Partial<IMaintenance> = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
(safeUpdate as any)[field] = updateData[field];
}
}
const updatedMaintenance = await Maintenance.findByIdAndUpdate(
id,
{
$set: {
...safeUpdate,
updatedAt: new Date(),
updatedBy: tokenizedUser.sub,
},
},
{ new: true, runValidators: true }
);
if (!updatedMaintenance) {
throw new ApiError("Failed to update maintenance", 500);
}
return updatedMaintenance;
};
delete = async (id: string) => {
const result = await Maintenance.deleteOne({ _id: id });
if (!result.deletedCount) {
throw new ApiError("Maintenance not found", 404);
}
return result.deletedCount === 1;
};
private refreshCache = async () => {
const now = new Date();
const activeMaintenances = await Maintenance.find({
isActive: true,
startTime: { $lte: now },
endTime: { $gte: now },
}).lean();
// Reset cache
const newCache = new Map();
for (const m of activeMaintenances) {
for (const monitorId of m.monitors) {
const key = monitorId.toString();
if (!newCache.has(key)) newCache.set(key, []);
newCache.get(key)!.push(m);
}
}
this.maintenanceCache = newCache;
this.lastRefresh = Date.now();
};
isInMaintenance = async (monitorId: string) => {
const now = Date.now();
if (now - this.lastRefresh > this.CACHE_TTL_MS) {
await this.refreshCache();
}
const maintenances = this.maintenanceCache.get(monitorId) || [];
return maintenances.length > 0;
};
}
export default MaintenanceService;

View File

@@ -0,0 +1,25 @@
import { MonitorStats } from "../../../db/v2/models/index.js";
import { Monitor } from "../../../db/v2/models/index.js";
export interface IMonitorStatsService {
cleanupOrphanedMonitorStats: () => Promise<boolean>;
}
class MonitorStatsService implements IMonitorStatsService {
constructor() {}
async cleanupOrphanedMonitorStats() {
try {
const monitorIds = await Monitor.find().distinct("_id");
const result = await MonitorStats.deleteMany({
monitorId: { $nin: monitorIds },
});
console.log(`Deleted ${result.deletedCount} orphaned MonitorStats.`);
return true;
} catch (error) {
console.error("Error cleaning up orphaned MonitorStats:", error);
return false;
}
}
}
export default MonitorStatsService;

View File

@@ -0,0 +1,100 @@
import { ITokenizedUser, INotificationChannel, NotificationChannel, Monitor } from "../../../db/v2/models/index.js";
import ApiError from "../../../utils/ApiError.js";
export interface INotificationChannelService {
create: (
tokenizedUser: ITokenizedUser,
notificationChannel: INotificationChannel
) => Promise<INotificationChannel>;
getAll: () => Promise<INotificationChannel[]>;
get: (id: string) => Promise<INotificationChannel>;
toggleActive: (tokenizedUser: ITokenizedUser, id: string) => Promise<INotificationChannel>;
update: (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<INotificationChannel>) => Promise<INotificationChannel>;
delete: (id: string) => Promise<boolean>;
}
class NotificationChannelService implements INotificationChannelService {
constructor() {}
create = async (tokenizedUser: ITokenizedUser, notificationChannelData: INotificationChannel) => {
const notificationChannel = await NotificationChannel.create({
...notificationChannelData,
createdBy: tokenizedUser.sub,
updatedBy: tokenizedUser.sub,
});
return notificationChannel;
};
get = async (id: string) => {
const channel = await NotificationChannel.findById(id);
if (!channel) {
throw new ApiError("Notification channel not found", 404);
}
return channel;
};
getAll = async () => {
return NotificationChannel.find();
};
toggleActive = async (tokenizedUser: ITokenizedUser, id: string) => {
const updatedChannel = await NotificationChannel.findOneAndUpdate(
{ _id: id },
[
{
$set: {
isActive: { $not: "$isActive" },
updatedBy: tokenizedUser.sub,
updatedAt: new Date(),
},
},
],
{ new: true }
);
if (!updatedChannel) {
throw new ApiError("Notification channel not found", 404);
}
return updatedChannel;
};
update = async (tokenizedUser: ITokenizedUser, id: string, updateData: Partial<INotificationChannel>) => {
const allowedFields: (keyof INotificationChannel)[] = ["name", "config", "isActive"];
const safeUpdate: Partial<INotificationChannel> = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
(safeUpdate as any)[field] = updateData[field];
}
}
const updatedChannel = await NotificationChannel.findByIdAndUpdate(
id,
{
$set: {
...safeUpdate,
updatedAt: new Date(),
updatedBy: tokenizedUser.sub,
},
},
{ new: true, runValidators: true }
);
if (!updatedChannel) {
throw new ApiError("Failed to update notification channel", 500);
}
return updatedChannel;
};
delete = async (id: string) => {
const result = await NotificationChannel.deleteOne({ _id: id });
if (!result.deletedCount) {
throw new ApiError("Notification channel not found", 404);
}
await Monitor.updateMany({ notificationChannels: id }, { $pull: { notificationChannels: id } });
return result.deletedCount === 1;
};
}
export default NotificationChannelService;

View File

@@ -0,0 +1,23 @@
import { IJobQueue } from "../infrastructure/JobQueue.js";
class QueueService {
private jobQueue: IJobQueue;
constructor(jobQueue: IJobQueue) {
this.jobQueue = jobQueue;
}
async getMetrics() {
return await this.jobQueue.getMetrics();
}
async getJobs() {
return await this.jobQueue.getJobs();
}
async flush() {
return await this.jobQueue.flush();
}
}
export default QueueService;

View File

@@ -0,0 +1,13 @@
import { IUser, User } from "../../../db/v2/models/index.js";
export interface IUserService {
getAllUsers(): Promise<IUser[]>;
}
class UserService implements IUserService {
async getAllUsers(): Promise<IUser[]> {
return await User.find();
}
}
export default UserService;

View File

@@ -0,0 +1,80 @@
import { IMonitor } from "../../../db/v2/models/index.js";
import { INetworkService } from "./NetworkService.js";
import { ICheckService } from "../business/CheckService.js";
import { IMonitorStatsService } from "../business/MonitorStatsService.js";
import { IStatusService } from "./StatusService.js";
import { INotificationService } from "./NotificationService.js";
import { IMaintenanceService } from "../business/MaintenanceService.js";
import ApiError from "../../../utils/ApiError.js";
export interface IJobGenerator {
generateJob: () => (Monitor: IMonitor) => Promise<void>;
generateCleanupJob: () => () => Promise<void>;
}
class JobGenerator implements IJobGenerator {
private networkService: INetworkService;
private checkService: ICheckService;
private monitorStatsService: IMonitorStatsService;
private statusService: IStatusService;
private notificationService: INotificationService;
private maintenanceService: IMaintenanceService;
constructor(
networkService: INetworkService,
checkService: ICheckService,
monitorStatsService: IMonitorStatsService,
statusService: IStatusService,
notificationService: INotificationService,
maintenanceService: IMaintenanceService
) {
this.networkService = networkService;
this.checkService = checkService;
this.monitorStatsService = monitorStatsService;
this.statusService = statusService;
this.notificationService = notificationService;
this.maintenanceService = maintenanceService;
}
generateJob = () => {
return async (monitor: IMonitor) => {
try {
const monitorId = monitor._id.toString();
if (!monitorId) {
throw new ApiError("No monitorID for creating job", 400);
}
// Check for active maintenance window, if found, skip the check
const isInMaintenance = await this.maintenanceService.isInMaintenance(monitorId);
if (isInMaintenance) {
return;
}
const status = await this.networkService.requestStatus(monitor);
const check = await this.checkService.buildCheck(status, monitor.type);
await check.save();
const [updatedMonitor, statusChanged] = await this.statusService.updateMonitorStatus(monitor, status);
if (statusChanged) {
await this.notificationService.handleNotifications(updatedMonitor);
}
await this.statusService.updateMonitorStats(updatedMonitor, status, statusChanged);
} catch (error) {
throw error;
}
};
};
generateCleanupJob = () => {
return async () => {
try {
await this.checkService.cleanupOrphanedChecks();
await this.monitorStatsService.cleanupOrphanedMonitorStats();
} catch (error) {
throw error;
}
};
};
}
export default JobGenerator;

View File

@@ -0,0 +1,227 @@
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";
export interface IJobMetrics {
jobs: number;
activeJobs: number;
failingJobs: number;
jobsWithFailures: Array<{
monitorId: string | number;
monitorUrl: string | null;
monitorType: string | null;
failedAt: number | null;
failCount: number | null;
failReason: string | null;
}>;
totalRuns: number;
totalFailures: number;
}
export interface IJobData extends IJob {
lastRunTook: number | null;
}
export interface IJobQueue {
init: () => Promise<boolean>;
addJob: (monitor: IMonitor) => Promise<boolean>;
pauseJob: (monitor: IMonitor) => Promise<boolean>;
resumeJob: (monitor: IMonitor) => Promise<boolean>;
updateJob: (monitor: IMonitor) => Promise<boolean>;
deleteJob: (monitor: IMonitor) => Promise<boolean>;
getMetrics: () => Promise<IJobMetrics | null>;
getJobs: () => Promise<IJobData[] | null>;
flush: () => Promise<boolean>;
shutdown: () => Promise<boolean>;
}
export default class JobQueue implements IJobQueue {
private scheduler: Scheduler;
private static instance: JobQueue | null = null;
private jobGenerator: any;
constructor() {
this.scheduler = new Scheduler({
logLevel: "debug",
});
}
static async create(jobGenerator: IJobGenerator) {
if (!JobQueue.instance) {
const instance = new JobQueue();
instance.jobGenerator = jobGenerator;
await instance.init();
JobQueue.instance = instance;
}
return JobQueue.instance;
}
static getInstance(): JobQueue | null {
return JobQueue.instance;
}
init = async () => {
try {
this.scheduler.start();
// Add template and jobs
this.scheduler.addTemplate("monitor-job", this.jobGenerator.generateJob());
// Add a cleanup job
this.scheduler.addTemplate("cleanup-job", this.jobGenerator.generateCleanupJob());
await this.scheduler.addJob({
id: "cleanup-orphaned-checks",
template: "cleanup-job",
repeat: 24 * 60 * 60 * 1000, // 24 hours
active: true,
});
const monitors = await Monitor.find();
for (const monitor of monitors) {
this.addJob(monitor);
}
return true;
} catch (error) {
console.error(error);
return false;
}
};
addJob = async (monitor: IMonitor) => {
try {
return await this.scheduler?.addJob({
id: monitor._id.toString(),
template: "monitor-job",
repeat: monitor.interval,
active: monitor.isActive,
data: monitor,
});
} catch (error) {
console.error(error);
return false;
}
};
pauseJob = async (monitor: IMonitor) => {
try {
return await this.scheduler?.pauseJob(monitor._id.toString());
} catch (error) {
console.error(error);
return false;
}
};
resumeJob = async (monitor: IMonitor) => {
try {
return await this.scheduler.resumeJob(monitor._id.toString());
} catch (error) {
console.error(error);
return false;
}
};
updateJob = async (monitor: IMonitor) => {
try {
return await this.scheduler.updateJob(monitor._id.toString(), {
repeat: monitor.interval,
data: monitor,
});
} catch (error) {
console.error(error);
return false;
}
};
deleteJob = async (monitor: IMonitor) => {
try {
this.scheduler?.removeJob(monitor._id.toString());
return true;
} catch (error) {
console.error(error);
return false;
}
};
getMetrics = async (): Promise<IJobMetrics | null> => {
try {
const jobs = await this.scheduler.getJobs();
const metrics: IJobMetrics = jobs.reduce<IJobMetrics>(
(acc, job) => {
if (!job.data) return acc;
acc.totalRuns += job.runCount || 0;
acc.totalFailures += job.failCount || 0;
acc.jobs++;
// Check if job is currently failing (has recent failures)
const hasFailures = job.failCount && job.failCount > 0;
const isCurrentlyFailing = hasFailures && job.lastFailedAt && (!job.lastRunAt || job.lastFailedAt > job.lastRunAt);
if (isCurrentlyFailing) {
acc.failingJobs++;
}
if (job.lockedAt) {
acc.activeJobs++;
}
if (hasFailures) {
acc.jobsWithFailures.push({
monitorId: job.id,
monitorUrl: job.data?.url || null,
monitorType: job.data?.type || null,
failedAt: job.lastFailedAt || null,
failCount: job.failCount || null,
failReason: job.lastFailReason || null,
});
}
return acc;
},
{
jobs: 0,
activeJobs: 0,
failingJobs: 0,
jobsWithFailures: [],
totalRuns: 0,
totalFailures: 0,
}
);
return metrics;
} catch (error) {
console.error(error);
return null;
}
};
getJobs = async (): Promise<IJobData[] | null> => {
try {
const jobs = await this.scheduler.getJobs();
return jobs.map((job) => {
return {
...job,
lastRunTook: job.lockedAt || !job.lastFinishedAt || !job.lastRunAt ? null : job.lastFinishedAt - job.lastRunAt,
};
});
} catch (error) {
console.error(error);
return null;
}
};
flush = async () => {
try {
return await this.scheduler.flushJobs();
} catch (error) {
console.error(error);
return false;
}
};
shutdown = async () => {
try {
return await this.scheduler.stop();
} catch (error) {
console.error(error);
return false;
}
};
}

View File

@@ -0,0 +1,193 @@
import { Got, HTTPError } from "got";
import ping from "ping";
import { IMonitor } from "../../../db/v2/models/index.js";
import { GotTimings } from "../../../db/v2/models/checks/Check.js";
import type { Response } from "got";
import type { ISystemInfo, ICaptureInfo, ILighthouseResult } from "../../../db/v2/models/index.js";
import { MonitorType, MonitorStatus } from "../../../db/v2/models/monitors/Monitor.js";
import ApiError from "../../../utils/ApiError.js";
import { config } from "../../../config/index.js";
export interface INetworkService {
requestHttp: (monitor: IMonitor) => Promise<StatusResponse>;
requestInfrastructure: (monitor: IMonitor) => Promise<StatusResponse>;
requestStatus: (monitor: IMonitor) => Promise<StatusResponse>;
requestPagespeed: (monitor: IMonitor) => Promise<StatusResponse>;
requestPing: (monitor: IMonitor) => Promise<StatusResponse>;
}
export interface ICapturePayload {
data: ISystemInfo;
capture: ICaptureInfo;
}
export interface ILighthousePayload {
lighthouseResult: ILighthouseResult;
}
export interface StatusResponse<TPayload = unknown> {
monitorId: string;
type: MonitorType;
code?: number;
status: MonitorStatus;
message: string;
responseTime: number;
timings?: GotTimings;
payload?: TPayload;
}
class NetworkService implements INetworkService {
private got: Got;
private NETWORK_ERROR: number;
constructor(got: Got) {
this.got = got;
this.NETWORK_ERROR = 5000;
}
private buildStatusResponse = <T>(monitor: IMonitor, response: Response<T> | null, error: any | null): StatusResponse<T> => {
if (error) {
const statusResponse: StatusResponse<T> = {
monitorId: monitor._id.toString(),
type: monitor.type,
status: "down" as MonitorStatus,
code: this.NETWORK_ERROR,
message: error.message || "Network error",
responseTime: 0,
timings: { phases: {} } as GotTimings,
};
if (error instanceof HTTPError) {
statusResponse.code = error?.response?.statusCode || this.NETWORK_ERROR;
statusResponse.message = error.message || "HTTP error";
statusResponse.responseTime = error.timings?.phases?.total || 0;
statusResponse.timings = error.timings;
}
return statusResponse;
}
const statusResponse: StatusResponse<T> = {
monitorId: monitor._id.toString(),
type: monitor.type,
code: response?.statusCode || this.NETWORK_ERROR,
status: response?.ok === true ? "up" : "down",
message: response?.statusMessage || "",
responseTime: response?.timings?.phases?.total || 0,
timings: response?.timings || ({ phases: {} } as GotTimings),
};
return statusResponse;
};
requestHttp = async (monitor: IMonitor) => {
try {
const url = monitor.url;
if (!url) {
throw new Error("No URL provided");
}
try {
const response: Response = await this.got(url);
return this.buildStatusResponse(monitor, response, null);
} catch (error) {
return this.buildStatusResponse(monitor, null, error);
}
} catch (error) {
throw error;
}
};
requestInfrastructure = async (monitor: IMonitor) => {
const url = monitor.url;
if (!url) {
throw new Error("No URL provided");
}
const secret = monitor.secret;
if (!secret) {
throw new Error("No secret provided for infrastructure monitor");
}
let statusResponse: StatusResponse<ICapturePayload>;
try {
const response: Response<ICapturePayload> | null = await this.got(url, {
headers: { Authorization: `Bearer ${secret}` },
responseType: "json",
});
statusResponse = this.buildStatusResponse(monitor, response, null);
if (!response?.body) {
throw new ApiError("No payload received from infrastructure monitor", 500);
}
statusResponse.payload = response?.body;
return statusResponse;
} catch (error) {
statusResponse = this.buildStatusResponse(monitor, null, error);
}
return statusResponse;
};
requestPagespeed = async (monitor: IMonitor) => {
const apiKey = config.PAGESPEED_API_KEY;
if (!apiKey) {
throw new Error("No API key provided for pagespeed monitor");
}
const url = monitor.url;
if (!url) {
throw new Error("No URL provided");
}
let statusResponse: StatusResponse<ILighthousePayload>;
try {
const response: Response = await this.got(url);
statusResponse = this.buildStatusResponse(monitor, response, null) as StatusResponse<ILighthousePayload>;
} catch (error) {
statusResponse = this.buildStatusResponse(monitor, null, error);
}
const pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance&key=${apiKey}`;
const pagespeedResponse = await this.got<ILighthousePayload>(pagespeedUrl, {
responseType: "json",
});
const payload = pagespeedResponse.body;
if (payload) {
statusResponse.payload = payload;
return statusResponse;
} else {
throw new ApiError("No payload received from pagespeed monitor", 500);
}
};
requestPing = async (monitor: IMonitor) => {
const response = await ping.promise.probe(monitor.url);
const status = response?.alive === true ? "up" : "down";
const rawTime = typeof response?.time === "string" ? parseFloat(response.time) : Number(response?.time);
const responseTime = Number.isFinite(rawTime) ? rawTime : 0;
return {
monitorId: monitor._id.toString(),
type: monitor.type,
status: status as MonitorStatus,
message: "Ping successful",
responseTime,
timings: { phases: {} } as GotTimings,
};
};
requestStatus = async (monitor: IMonitor) => {
switch (monitor?.type) {
case "http":
return await this.requestHttp(monitor); // uses GOT
case "https":
return await this.requestHttp(monitor); // uses GOT
case "infrastructure":
return await this.requestInfrastructure(monitor); // uses GOT
case "pagespeed":
return await this.requestPagespeed(monitor); // uses GOT
case "ping":
return await this.requestPing(monitor); // uses PING
default:
throw new Error("Not implemented");
}
};
}
export default NetworkService;

View File

@@ -0,0 +1,58 @@
import UserService from "../business/UserService.js";
import { IMonitor, NotificationChannel } from "../../../db/v2/models/index.js";
import { EmailService, SlackService, DiscordService, WebhookService } from "./NotificationServices/index.js";
export interface INotificationService {
handleNotifications: (monitor: IMonitor) => Promise<void>;
}
class NotificationService implements INotificationService {
private emailService: EmailService;
private slackService: SlackService;
private discordService: DiscordService;
private webhookService: WebhookService;
private userService: UserService;
constructor(userService: UserService) {
this.userService = userService;
this.emailService = new EmailService(userService);
this.slackService = new SlackService();
this.discordService = new DiscordService();
this.webhookService = new WebhookService();
}
handleNotifications = async (monitor: IMonitor) => {
const notificationIds = monitor.notificationChannels || [];
if (notificationIds.length === 0) {
return;
}
const notificationChannels = await NotificationChannel.find({
_id: { $in: notificationIds },
});
for (const channel of notificationChannels) {
// Implement sending logic based on channel.type and channel.config
let service;
switch (channel.type) {
case "email":
await this.emailService.sendMessage(this.emailService.buildAlert(monitor), channel);
break;
case "slack":
await this.slackService.sendMessage(this.slackService.buildAlert(monitor), channel);
break;
case "discord":
await this.discordService.sendMessage(this.discordService.buildAlert(monitor), channel);
break;
case "webhook":
await this.webhookService.sendMessage(this.webhookService.buildAlert(monitor), channel);
break;
default:
console.warn(`Unknown notification channel type: ${channel.type}`);
}
}
return;
};
}
export default NotificationService;

View File

@@ -0,0 +1,73 @@
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
import { IAlert, IMessageService } from "./IMessageService.js";
import got from "got";
import ApiError from "../../../../utils/ApiError.js";
class DiscordService implements IMessageService {
constructor() {}
private toDiscordEmbeds = (alert: IAlert) => {
return {
color: alert.status === "up" ? 65280 : 16711680,
title: `Monitor name: ${alert.name}`,
description: `Status: **${alert.status}**`,
fields: [
{
name: "Url",
value: alert.url,
},
{
name: "Checked at",
value: alert.checkTime ? alert.checkTime.toISOString() : "N/A",
},
{ name: "Alert time", value: alert.alertTime.toISOString() },
...(alert.details
? Object.entries(alert.details).map(([key, value]) => ({
name: key,
value,
}))
: []),
],
};
};
buildAlert = (monitor: IMonitor) => {
const name = monitor?.name || "Unnamed monitor";
const monitorStatus = monitor?.status || "unknown status";
const url = monitor?.url || "no URL";
const checkTime = monitor?.lastCheckedAt || null;
const alertTime = new Date();
return {
name,
url,
status: monitorStatus,
checkTime,
alertTime,
};
};
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
const notificationUrl = channel?.config?.url;
if (!notificationUrl) {
throw new ApiError("Webhook URL not configured", 400);
}
try {
const payload = {
content: "Status Alert",
embeds: [this.toDiscordEmbeds(alert)],
};
await got.post(notificationUrl, { json: payload });
} catch (error) {
console.warn("Failed to send Discord message", error);
return false;
}
return true;
};
testMessage = async () => {
return true;
};
}
export default DiscordService;

View File

@@ -0,0 +1,65 @@
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
import { IMessageService, IAlert } from "./IMessageService.js";
import nodemailer, { Transporter } from "nodemailer";
import { config } from "../../../../config/index.js";
import UserService from "../../business/UserService.js";
import ApiError from "../../../../utils/ApiError.js";
class EmailService implements IMessageService {
private transporter: Transporter;
private userService: UserService;
constructor(userService: UserService) {
this.userService = userService;
this.transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_PORT === 465,
auth: {
user: config.SMTP_USER,
pass: config.SMTP_PASS,
},
});
}
buildAlert = (monitor: IMonitor) => {
const name = monitor?.name || "Unnamed monitor";
const monitorStatus = monitor?.status || "unknown status";
const url = monitor?.url || "no URL";
const checkTime = monitor?.lastCheckedAt || null;
const alertTime = new Date();
return {
name,
url,
status: monitorStatus,
checkTime,
alertTime,
};
};
sendMessage = async (alert: string | IAlert, channel: INotificationChannel) => {
try {
const users = await this.userService.getAllUsers();
const emails = users.map((u) => u.email).join(",");
if (!emails || emails.length === 0) {
throw new ApiError("No user emails found", 500);
}
await this.transporter.sendMail({
from: `"Checkmate" <${config.SMTP_USER}>`,
to: emails,
subject: "Monitor Alert",
text: JSON.stringify(alert, null, 2),
});
return true;
} catch (error) {
return false;
}
};
testMessage = async () => {
return true;
};
}
export default EmailService;

View File

@@ -0,0 +1,15 @@
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
export interface IAlert {
name: string;
url: string;
status: string;
details?: Record<string, string>;
checkTime: Date | null;
alertTime: Date;
}
export interface IMessageService {
buildAlert: (monitor: IMonitor) => IAlert;
sendMessage: (alert: IAlert, channel: INotificationChannel) => Promise<boolean>;
testMessage: (message: string, channel: INotificationChannel) => Promise<boolean>;
}

View File

@@ -0,0 +1,116 @@
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
import { IAlert, IMessageService } from "./IMessageService.js";
import got from "got";
class SlackService implements IMessageService {
constructor() {}
private toSlackBlocks = (alert: IAlert) => {
return [
{
type: "header",
text: {
type: "plain_text",
text: "Status Alert",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Monitor name:* ${alert.name}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Status:* ${alert.status}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*URL:* ${alert.url}`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Checked at:* ${alert?.checkTime?.toISOString() || "N/A"}`,
},
},
{
type: "divider",
},
...(alert.details
? Object.entries(alert.details).map(([key, value]) => ({
type: "section",
fields: [
{
type: "mrkdwn",
text: `*${key}:* ${value}`,
},
],
}))
: []),
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `*Alert generated at:* ${alert?.alertTime?.toISOString() || "N/A"}`,
},
],
},
];
};
buildAlert = (monitor: IMonitor) => {
const name = monitor?.name || "Unnamed monitor";
const monitorStatus = monitor?.status || "unknown status";
const url = monitor?.url || "no URL";
const checkTime = monitor?.lastCheckedAt || null;
const alertTime = new Date();
return {
name,
url,
status: monitorStatus,
checkTime,
alertTime,
};
};
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
const notificationUrl = channel?.config?.url;
if (!notificationUrl) {
throw new Error("Webhook URL not configured");
}
try {
const payload = {
text: "Status Alert",
blocks: this.toSlackBlocks(alert),
};
await got.post(notificationUrl, { json: payload });
} catch (error) {
console.warn("Error sending Slack message:", error);
return false;
}
return true;
};
testMessage = async () => {
return true;
};
}
export default SlackService;

View File

@@ -0,0 +1,43 @@
import { IMonitor, INotificationChannel } from "../../../../db/v2/models/index.js";
import { IAlert, IMessageService } from "./IMessageService.js";
import ApiError from "../../../../utils/ApiError.js";
import got from "got";
class WebhookService implements IMessageService {
constructor() {}
buildAlert = (monitor: IMonitor) => {
const name = monitor?.name || "Unnamed monitor";
const monitorStatus = monitor?.status || "unknown status";
const url = monitor?.url || "no URL";
const checkTime = monitor?.lastCheckedAt || null;
const alertTime = new Date();
return {
name,
url,
status: monitorStatus,
checkTime,
alertTime,
};
};
sendMessage = async (alert: IAlert, channel: INotificationChannel) => {
const notificationUrl = channel?.config?.url;
if (!notificationUrl) {
throw new ApiError("Webhook URL not configured", 400);
}
try {
await got.post(notificationUrl, { json: { ...alert } });
} catch (error) {
console.warn("Failed to send webhook notification:", error);
return false;
}
return true;
};
testMessage = async () => {
return true;
};
}
export default WebhookService;

View File

@@ -0,0 +1,5 @@
export { default as DiscordService } from "./Discord.js";
export { default as EmailService } from "./Email.js";
export { default as SlackService } from "./Slack.js";
export { default as WebhookService } from "./Webhook.js";
export * from "./IMessageService.js";

View File

@@ -0,0 +1,100 @@
import { IMonitor, IMonitorStats, MonitorStats } from "../../../db/v2/models/index.js";
import { StatusResponse } from "./NetworkService.js";
import ApiError from "../../../utils/ApiError.js";
const MAX_LATEST_CHECKS = 25;
export interface IStatusService {
updateMonitorStatus: (monitor: IMonitor, status: StatusResponse) => Promise<StatusChangeResult>;
calculateAvgResponseTime: (stats: IMonitorStats, statusResponse: StatusResponse) => number;
updateMonitorStats: (monitor: IMonitor, status: StatusResponse, statusChanged: boolean) => Promise<IMonitorStats | null>;
}
export type StatusChangeResult = [updatedMonitor: IMonitor, statusChanged: boolean];
class StatusService implements IStatusService {
updateMonitorStatus = async (monitor: IMonitor, statusResponse: StatusResponse): Promise<StatusChangeResult> => {
const newStatus = statusResponse.status;
monitor.lastCheckedAt = new Date();
// Store latest checks for display
monitor.latestChecks = monitor.latestChecks || [];
monitor.latestChecks.push({
status: newStatus,
responseTime: statusResponse.responseTime,
checkedAt: monitor.lastCheckedAt,
});
while (monitor.latestChecks.length > MAX_LATEST_CHECKS) {
monitor.latestChecks.shift();
}
// Update monitor status
if (monitor.status === "initializing") {
monitor.status = newStatus;
return [await monitor.save(), true];
} else {
const { n } = monitor;
const latestChecks = monitor.latestChecks.slice(-n);
// Return early if not enough statuses to evaluate
if (latestChecks.length < n) {
return [await monitor.save(), false];
}
// If all different than current status, update status
const allDifferent = latestChecks.every((check) => check.status !== monitor.status);
if (allDifferent && monitor.status !== newStatus) {
monitor.status = newStatus;
}
return [await monitor.save(), allDifferent];
}
};
calculateAvgResponseTime = (stats: IMonitorStats, statusResponse: StatusResponse): number => {
let avgResponseTime = stats.avgResponseTime;
// Set initial
if (avgResponseTime === 0) {
avgResponseTime = statusResponse.responseTime;
} else {
avgResponseTime = (avgResponseTime * (stats.totalChecks - 1) + statusResponse.responseTime) / stats.totalChecks;
}
return avgResponseTime;
};
updateMonitorStats = async (monitor: IMonitor, statusResponse: StatusResponse, statusChanged: boolean) => {
const stats = await MonitorStats.findOne({ monitorId: monitor._id });
if (!stats) {
throw new ApiError("MonitorStats not found", 500);
}
// Update check counts
stats.totalChecks += 1;
stats.totalUpChecks += statusResponse.status === "up" ? 1 : 0;
stats.totalDownChecks += statusResponse.status === "down" ? 1 : 0;
// Update streak
if (!statusChanged) {
stats.currentStreak += 1;
} else {
stats.currentStreak = 1;
stats.currentStreakStatus = statusResponse.status;
stats.currentStreakStartedAt = Date.now();
}
// Update time stamps
stats.lastCheckTimestamp = Date.now();
stats.timeOfLastFailure = statusResponse.status === "down" ? Date.now() : stats.timeOfLastFailure;
// Update stats that need updated check counts
stats.avgResponseTime = this.calculateAvgResponseTime(stats, statusResponse);
stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks;
// Other
stats.lastResponseTime = statusResponse.responseTime;
stats.maxResponseTime = Math.max(stats.maxResponseTime, statusResponse.responseTime);
return await stats.save();
};
}
export default StatusService;

View File

@@ -0,0 +1,15 @@
class ApiError extends Error {
public status: number;
constructor(message: string, status: number = 500) {
super(message);
this.status = status;
this.name = this.constructor.name;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
export default ApiError;

View File

@@ -0,0 +1,22 @@
import jwt from "jsonwebtoken";
import { AuthResult } from "../service/v2/business/AuthService.js";
const encode = (data: AuthResult): string => {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not defined");
}
const token = jwt.sign(data, secret, { expiresIn: "99d" });
return token;
};
const decode = (token: string): AuthResult => {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not defined");
}
const decoded = jwt.verify(token, secret) as AuthResult;
return decoded;
};
export { encode, decode };