Files
Checkmate/client/src/validation/zod.ts
T
2025-12-07 03:34:33 +00:00

430 lines
13 KiB
TypeScript

import { z } from "zod";
import humanInterval from "human-interval";
import { ChannelTypes } from "@/types/notification-channel";
import { MaintenanceRepeats } from "@/types/maintenance";
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" }),
confirmPassword: z
.string()
.min(6, { message: "Confirm Password must be at least 6 characters" }),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords must match",
});
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})?(\/.*)?$/;
const durationSchema = z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val || val.trim() === "") return;
const ms = humanInterval(val);
if (!ms || isNaN(ms)) {
ctx.addIssue({
code: "custom",
message: "Invalid duration format",
});
} else if (ms < 10000) {
ctx.addIssue({
code: "custom",
message: "Minimum duration is 10 seconds",
});
}
});
const durationSchemaPageSpeed = z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val || val.trim() === "") return;
const ms = humanInterval(val);
if (!ms || isNaN(ms)) {
ctx.addIssue({
code: "custom",
message: "Invalid duration format",
});
} else if (ms < 180000) {
ctx.addIssue({
code: "custom",
message: "Minimum duration is 3 minutes",
});
}
});
const durationSchemaInfra = z
.string()
.optional()
.superRefine((val, ctx) => {
if (!val || val.trim() === "") return;
const ms = humanInterval(val);
if (!ms || isNaN(ms)) {
ctx.addIssue({
code: "custom",
message: "Invalid duration format",
});
} else if (ms < 60000) {
ctx.addIssue({
code: "custom",
message: "Minimum duration is 1 minute",
});
}
});
const optionalString = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess(
(val) =>
val === "" || val === null || val === undefined ? undefined : val,
schema.optional()
);
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"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
port: z.coerce.number().optional(),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchema,
rejectUnauthorized: z.boolean().default(true),
})
.superRefine((data, ctx) => {
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"],
});
}
}
})
.transform((data) => {
if (data.type !== "port") {
return { ...data, port: undefined };
}
return data;
});
export const monitorSchemaPageSpeed = z.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchemaPageSpeed,
rejectUnauthorized: z.boolean().default(true),
});
export const monitorSchemaInfra = z.object({
type: z.string().min(1, "You must select an option"),
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
secret: z.string().min(1, "Secret is required"),
n: z.coerce
.number({ message: "Number required" })
.min(1, "Minimum value is 1")
.max(25, "Maximum value is 25"),
notificationChannels: z.array(z.string()).optional().default([]),
name: z.string().min(1, "Display name is required"),
interval: durationSchemaInfra,
rejectUnauthorized: z.boolean().default(true),
thresholds: z.object({
// Accept both strings and numbers from the form. Enforce required and ranges with clear messages.
cpu: z.preprocess(
(val) => (typeof val === "number" ? String(val) : val),
z
.string()
.trim()
.min(1, { message: "CPU threshold is required" })
.refine((v) => /^-?\d+(\.\d+)?$/.test(v), {
message: "CPU must be a number",
})
.transform((v) => Number(v))
.refine((v) => v >= 0 && v <= 100, {
message: "CPU must be between 0 and 100",
})
),
memory: z.preprocess(
(val) => (typeof val === "number" ? String(val) : val),
z
.string()
.trim()
.min(1, { message: "Memory threshold is required" })
.refine((v) => /^-?\d+(\.\d+)?$/.test(v), {
message: "Memory must be a number",
})
.transform((v) => Number(v))
.refine((v) => v >= 0 && v <= 100, {
message: "Memory must be between 0 and 100",
})
),
disk: z.preprocess(
(val) => (typeof val === "number" ? String(val) : val),
z
.string()
.trim()
.min(1, { message: "Disk threshold is required" })
.refine((v) => /^-?\d+(\.\d+)?$/.test(v), {
message: "Disk must be a number",
})
.transform((v) => Number(v))
.refine((v) => v >= 0 && v <= 100, {
message: "Disk must be between 0 and 100",
})
),
temperature: z.preprocess(
(val) => (typeof val === "number" ? String(val) : val),
z
.string()
.trim()
.min(1, { message: "Temperature threshold is required" })
.refine((v) => /^-?\d+(\.\d+)?$/.test(v), {
message: "Temperature must be a number",
})
.transform((v) => Number(v))
.refine((v) => v >= -50 && v <= 150, {
message: "Temperature must be between -50 and 150",
})
),
}),
});
export const teamSchema = z.object({
name: z.string().min(1, "Team name is required"),
roleId: z.string().min(1, "Role is required"),
description: z.string().optional(),
});
export const teamMemberSchema = z.object({
userId: z.string().min(1, "User is required"),
roleId: z.string().min(1, "Role is required"),
teamId: z.string().min(1, "Team is required"),
});
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(),
});
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 maintenanceSchema = z
.object({
name: z.string().min(1, "Maintenance name is required"),
repeat: z.enum(MaintenanceRepeats),
startTime: z.date(),
endTime: z.date(),
monitors: z.array(z.string()),
})
.superRefine((data, ctx) => {
if (data.endTime <= data.startTime) {
ctx.addIssue({
code: "custom",
path: ["endTime"],
message: "End time must be after start time",
});
}
if (data.startTime === data.endTime) {
ctx.addIssue({
code: "custom",
path: ["endTime"],
message: "Start time and end time cannot be the same",
});
}
});
export const systemSettingsSchema = z.object({
systemEmailHost: optionalString(
z.string().trim().min(1, { message: "SMTP host is required" }).max(255, {
message: "SMTP host must be 255 characters or fewer",
})
),
systemEmailPort: optionalString(
z
.union([z.number().int(), z.string().regex(/^\d+$/)])
.transform((val) => (typeof val === "string" ? Number(val) : val))
.refine(
(val) =>
val === undefined ||
(Number.isInteger(val) && val > 0 && val < 65536),
{
message: "Port must be a number between 1 and 65535",
}
)
),
systemEmailAddress: optionalString(
z.string().email({ message: "SMTP email must be valid" })
),
systemEmailPassword: optionalString(
z.string().max(255, {
message: "SMTP password must be 255 characters or fewer",
})
),
systemEmailUser: optionalString(
z.string().trim().max(255, {
message: "SMTP username must be 255 characters or fewer",
})
),
systemEmailConnectionHost: optionalString(
z.string().trim().max(255, {
message: "Connection host must be 255 characters or fewer",
})
),
systemEmailTLSServername: optionalString(
z.string().trim().max(255, {
message: "TLS server name must be 255 characters or fewer",
})
),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
systemEmailIgnoreTLS: z.boolean().optional(),
systemEmailRequireTLS: z.boolean().optional(),
systemEmailRejectUnauthorized: z.boolean().optional(),
checksRetentionDays: z.coerce
.number({ message: "Number required" })
.min(1, "Retention days must be at least 1")
.max(365, "Retention days cannot exceed 365 days"),
});
const statusPageUrlRegex = /^[A-Za-z0-9]+$/;
export const statusPageSchema = z.object({
name: z.string().min(1, "Status page name is required"),
description: z.string().optional(),
url: z
.string()
.min(1, "URL is required")
.regex(statusPageUrlRegex, "Invalid URL"),
isPublished: z.boolean().optional(),
monitors: z.array(z.string()),
});
export const recoverySchema = z.object({
email: z.email({ message: "Invalid email address" }).trim().toLowerCase(),
});
export const resetSchema = z
.object({
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
confirmPassword: z
.string()
.min(6, { message: "Confirm Password must be at least 6 characters" }),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords must match",
});
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"],
}
);