Files
Checkmate/server/src/validation/index.ts
T
2025-12-04 21:52:50 +00:00

669 lines
18 KiB
TypeScript

import {
ChannelTypes,
MonitorStatuses,
MonitorTypes,
ResolutionTypes,
} from "@/db/models/index.js";
import { z } from "zod";
import mongoose from "mongoose";
import { PlanKeys, type PlanKey } from "@/types/entitlements.js";
export const registerSchema = z.object({
email: z.email({ message: "Invalid email address" }),
firstName: z.string().min(1, { message: "First Name is required" }),
lastName: z.string().min(1, { message: "Last Name is required" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
});
export const registerWithInviteSchema = z.object({
firstName: z
.string()
.min(1, { message: "First Name is required" })
.optional(),
lastName: z.string().min(1, { message: "Last Name is required" }).optional(),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" })
.optional(),
confirmPassword: z
.string()
.min(6, { message: "Password must be at least 6 characters" })
.optional(),
});
export const loginSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export const inviteSchema = z.object({
email: z.email("Invalid email address"),
teamId: z.string().min(1, "Team is required"),
teamRoleId: z.string().min(1, "Role is required"),
orgRoleId: z.string().optional(),
});
const urlRegex =
/^(https?:\/\/)?(localhost|([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|[a-zA-Z0-9-]+|(\d{1,3}\.){3}\d{1,3})(:\d{1,5})?(\/.*)?$/;
export const monitorSchema = z
.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
name: z.string().min(1, "Display name is required"),
port: z.coerce.number().optional(),
n: z
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
notificationChannels: z.array(z.string()).optional().default([]),
secret: z.string().optional(),
interval: z.number({ message: "Interval required" }),
rejectUnauthorized: z.boolean().default(true),
thresholds: z
.object({
cpu: z.number().min(0).max(100).optional(),
memory: z.number().min(0).max(100).optional(),
disk: z.number().min(0).max(100).optional(),
temperature: z.number().min(0).max(150).optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
const minIntervals: Record<string, number> = {
http: 10000,
ping: 10000,
pagespeed: 100000,
};
const minInterval = minIntervals[data.type];
if (minInterval && data.interval < minInterval) {
ctx.addIssue({
code: "custom",
message: `Minimum interval for ${data.type} monitors is ${minInterval} ms`,
});
}
if (data.type === "infrastructure" && !data.secret) {
ctx.addIssue({
code: "custom",
message: `Secret is required for infrastructure monitors`,
});
}
if (data.type === "port") {
if (!data.port) {
ctx.addIssue({
code: "custom",
message: "Port is required for port monitoring",
path: ["port"],
});
}
if (isNaN(Number(data.port))) {
ctx.addIssue({
code: "custom",
message: "Port must be a number",
path: ["port"],
});
}
if (Number(data.port) < 1 || Number(data.port) > 65535) {
ctx.addIssue({
code: "custom",
message: "Port must be between 1 and 65535",
path: ["port"],
});
}
}
});
export const monitorImportSchema = z.object({
monitors: z
.array(
z
.object({
name: z.string().trim().min(1, "Monitor name is required"),
url: z.string().trim().regex(urlRegex, "Invalid URL"),
port: z.number().min(1).max(65535).optional(),
secret: z.string().trim().optional(),
type: z.enum(MonitorTypes),
interval: z.number().min(1, "Interval must be greater than 0"),
n: z.number().min(1, "n must be at least 1"),
})
.strict()
)
.min(1, { message: "Provide at least one monitor" }),
});
export const subscribePlanSchema = z.object({
planKey: z.enum(PlanKeys).optional(),
});
export type MonitorImportPayload = z.infer<typeof monitorImportSchema>;
export const monitorPatchSchema = monitorSchema
.omit({
url: true,
})
.partial()
.superRefine((data, ctx) => {
const minIntervals: Record<string, number> = {
http: 10000,
https: 10000,
ping: 10000,
pagespeed: 180000,
infrastructure: 60000,
};
if (!data.type || !data.interval) return;
const minInterval = minIntervals[data.type];
if (minInterval && data.interval < minInterval) {
ctx.addIssue({
code: "custom",
message: `Minimum interval for ${data.type} monitors is ${minInterval} ms`,
});
}
});
export const monitorAllEmbedChecksQuerySchema = z.object({
embedChecks: z
.string()
.optional()
.transform((val, ctx) => {
if (val === undefined) return undefined;
if (val === "true") return true;
if (val === "false") return false;
ctx.addIssue({
code: "custom",
message: "embedChecks must be 'true' or 'false'",
});
return z.NEVER;
}),
type: z
.union([z.string().optional(), z.string().optional().array()])
.optional()
.transform((val) => {
if (!val) return [];
return Array.isArray(val) ? val : [val];
}),
status: z
.union([z.string().optional(), z.string().optional().array()])
.optional()
.transform((val) => {
if (!val) return [];
return Array.isArray(val) ? val : [val];
}),
search: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.string().optional(),
page: z
.string()
.optional()
.transform((val, ctx) => {
const num = Number(val);
if (!val) return;
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "page must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "page must greater than 0",
});
return z.NEVER;
}
return num;
}),
rowsPerPage: z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return;
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be greater than 0",
});
return z.NEVER;
}
if (num > 100) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be less than or equal to 100",
});
return z.NEVER;
}
return num;
}),
});
export const monitorIdChecksQuerySchema = z.object({
page: z.string().transform((val, ctx) => {
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "page must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "page must greater than 0",
});
return z.NEVER;
}
return num;
}),
rowsPerPage: z.string().transform((val, ctx) => {
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be greater than 0",
});
return z.NEVER;
}
if (num > 100) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be less than or equal to 100",
});
return z.NEVER;
}
return num;
}),
});
export const monitorIdQuerySchema = z.object({
embedChecks: z
.string()
.transform((val) => val === "true")
.optional(),
range: z.string().optional(),
status: z.string().optional(),
});
export const notificationChannelSchema = z
.object({
name: z.string().min(1, "Channel name is required"),
type: z.enum(ChannelTypes, { error: "Invalid channel type" }),
config: z.object({
url: z
.string()
.regex(urlRegex, "Invalid URL")
.or(z.literal(""))
.optional(),
emailAddress: z
.email("Invalid email address")
.or(z.literal(""))
.optional(),
}),
})
.superRefine((data, ctx) => {
const { type, config } = data;
if (!config.url && !config.emailAddress) {
ctx.addIssue({
code: "custom",
message: "Either a URL or an email address must be provided.",
path: ["config"],
});
}
if (type === "email" && !config.emailAddress) {
ctx.addIssue({
code: "custom",
message: "Email address is required for email-type channels.",
path: ["config", "emailAddress"],
});
} else if (type !== "email" && !config.url) {
ctx.addIssue({
code: "custom",
message: "URL is required for non-email-type channels.",
path: ["config", "url"],
});
}
});
export const notificationPatchSchema = z
.object({
name: z.string().min(1).optional(),
type: z.enum(ChannelTypes).optional(),
config: z
.object({
url: z
.string()
.regex(urlRegex, "Invalid URL")
.or(z.literal(""))
.optional(),
emailAddress: z
.string()
.email("Invalid email address")
.or(z.literal(""))
.optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
const { type, config } = data;
if (!config) return;
const hasUrl = config.url !== undefined && config.url !== "";
const hasEmail =
config.emailAddress !== undefined && config.emailAddress !== "";
if (!hasUrl && !hasEmail) {
ctx.addIssue({
code: "custom",
message: "Either a URL or an email address must be provided.",
path: ["config"],
});
}
if (hasUrl && hasEmail) {
ctx.addIssue({
code: "custom",
message: "Cannot provide both URL and email address at the same time.",
path: ["config"],
});
}
if (type === "email" && !hasEmail) {
ctx.addIssue({
code: "custom",
message: "Email address is required for email-type channels.",
path: ["config", "emailAddress"],
});
} else if (type && type !== "email" && !hasUrl) {
ctx.addIssue({
code: "custom",
message: "URL is required for non-email-type channels.",
path: ["config", "url"],
});
}
});
export const checksStatusIdQuerySchema = z.object({
status: z.enum(MonitorStatuses, { error: "Invalid status" }),
monitorId: z
.string()
.refine((val) => mongoose.Types.ObjectId.isValid(val), {
message: "Invalid MongoDB ObjectId",
})
.optional(),
page: z.string().transform((val, ctx) => {
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "page must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "page must greater than 0",
});
return z.NEVER;
}
return num;
}),
rowsPerPage: z.string().transform((val, ctx) => {
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be greater than 0",
});
return z.NEVER;
}
if (num > 100) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be less than or equal to 100",
});
return z.NEVER;
}
return num;
}),
range: z.string().min(1),
});
export const statusPageSchema = z.object({
name: z.string().min(1, "Status page name is required"),
url: z.string().min(1, "Status page URL is required"),
description: z.string().optional(),
isPublished: z.boolean(),
monitors: z.array(z.string()).optional().default([]),
});
export const recoverySchema = z.object({
email: z.email({ message: "Invalid email address" }),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, { message: "Token is required" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
});
export const profileSchema = z
.object({
firstName: z
.string()
.min(1, { message: "First Name must be at least one char" })
.optional(),
lastName: z
.string()
.min(1, { message: "Last Name must be at least one char" })
.optional(),
password: z.preprocess(
(val) => (val === "" ? undefined : val),
z
.string()
.min(6, { message: "Password must be at least 6 characters" })
.optional()
),
confirmPassword: z.preprocess(
(val) => (val === "" ? undefined : val),
z
.string()
.min(6, { message: "Confirm Password must be at least 6 characters" })
.optional()
),
})
.refine(
(data) => {
if (!data.password && !data.confirmPassword) return true;
if (!data.password || !data.confirmPassword) return false;
return data.password === data.confirmPassword;
},
{
message: "Passwords must match",
path: ["confirmPassword"],
}
);
export const getIncidentsQuerySchema = z.object({
resolutionType: z.enum(ResolutionTypes).optional(),
resolved: z
.string()
.optional()
.transform((val, ctx) => {
if (val === undefined) return undefined;
if (val === "true") return true;
if (val === "false") return false;
ctx.addIssue({
code: "custom",
message: "resolved must be 'true' or 'false'",
});
return z.NEVER;
}),
page: z
.string()
.optional()
.transform((val, ctx) => {
const num = Number(val);
if (!val) return;
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "page must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "page must greater than 0",
});
return z.NEVER;
}
return num;
}),
rowsPerPage: z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return;
const num = Number(val);
if (Number.isNaN(num)) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be a number",
});
return z.NEVER;
}
if (num < 0) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be greater than 0",
});
return z.NEVER;
}
if (num > 100) {
ctx.addIssue({
code: "custom",
message: "rowsPerPage must be less than or equal to 100",
});
return z.NEVER;
}
return num;
}),
range: z.string().optional(),
monitorId: z
.string()
.refine((val) => mongoose.Types.ObjectId.isValid(val), {
message: "Invalid MongoDB ObjectId",
})
.optional(),
});
export const patchIncidentsBodySchema = z.object({
resolutionNote: z.string().max(200).optional(),
});
export const systemSettingsSchema = z
.object({
systemEmailHost: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().min(1).max(255).optional()
),
systemEmailPort: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z
.union([z.number().int(), z.string().regex(/^\d+$/)])
.transform((port) => (typeof port === "string" ? Number(port) : port))
.refine(
(port) =>
port === undefined ||
(Number.isInteger(port) && port > 0 && port < 65536),
{
message: "Port must be between 1 and 65535",
}
)
.optional()
),
systemEmailAddress: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().email().optional()
),
systemEmailPassword: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailUser: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailConnectionHost: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailTLSServername: z.preprocess(
(val) => (val === "" || val === null ? undefined : val),
z.string().trim().max(255).optional()
),
systemEmailSecure: z
.union([z.boolean(), z.string()])
.optional()
.transform((val) => (typeof val === "string" ? val === "true" : val)),
systemEmailPool: z
.union([z.boolean(), z.string()])
.optional()
.transform((val) => (typeof val === "string" ? val === "true" : val)),
systemEmailIgnoreTLS: z
.union([z.boolean(), z.string()])
.optional()
.transform((val) => (typeof val === "string" ? val === "true" : val)),
systemEmailRequireTLS: z
.union([z.boolean(), z.string()])
.optional()
.transform((val) => (typeof val === "string" ? val === "true" : val)),
systemEmailRejectUnauthorized: z
.union([z.boolean(), z.string()])
.optional()
.transform((val) => (typeof val === "string" ? val === "true" : val)),
})
.strip();
export const updateRetentionPolicySchema = z.object({
checksRetentionDays: z
.number()
.min(1, "Retention days must be at least 1")
.max(365, "Retention days cannot exceed 365 days"),
});