From 08887bbc585f9f836b535053b05f7aecb8d3999c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Nov 2025 23:37:29 +0000 Subject: [PATCH] checks retention routes --- server/src/controllers/SettingsController.ts | 22 ++++++++ server/src/db/models/system/SystemSettings.ts | 2 + server/src/routes/settings.ts | 14 ++++- server/src/services/system/SettingsService.ts | 55 +++++++++++++++++++ server/src/utils/FeatureFlags.ts | 7 ++- server/src/validation/index.ts | 7 +++ 6 files changed, 104 insertions(+), 3 deletions(-) diff --git a/server/src/controllers/SettingsController.ts b/server/src/controllers/SettingsController.ts index f1ed5c2a0..39e40e780 100644 --- a/server/src/controllers/SettingsController.ts +++ b/server/src/controllers/SettingsController.ts @@ -9,6 +9,11 @@ export interface ISettingsController { res: Response, next: NextFunction ) => void; + updateRetentionPolicy: ( + req: Request, + res: Response, + next: NextFunction + ) => void; testTransport: (req: Request, res: Response, next: NextFunction) => void; } @@ -53,6 +58,23 @@ class SettingsController implements ISettingsController { } }; + updateRetentionPolicy = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + try { + const retentionDays = req.body.checksRetentionDays; + const updatedRetentionPolicy = + await this.settingsService.updateRetentionPolicy(retentionDays); + res.status(200).json({ + message: `Retention policy set to ${updatedRetentionPolicy} days`, + }); + } catch (error) { + next(error); + } + }; + testTransport = async (req: Request, res: Response, next: NextFunction) => { try { const testResult = await this.notificationService.testEmailTransport( diff --git a/server/src/db/models/system/SystemSettings.ts b/server/src/db/models/system/SystemSettings.ts index 12291b690..913ca8e2f 100644 --- a/server/src/db/models/system/SystemSettings.ts +++ b/server/src/db/models/system/SystemSettings.ts @@ -13,6 +13,7 @@ export interface ISystemSettings extends Document { systemEmailIgnoreTLS: boolean; systemEmailRequireTLS: boolean; systemEmailRejectUnauthorized: boolean; + dataRetentionDays: number; } const SystemSettingsSchema = new Schema( @@ -30,6 +31,7 @@ const SystemSettingsSchema = new Schema( systemEmailIgnoreTLS: { type: Boolean, default: false }, systemEmailRequireTLS: { type: Boolean, default: false }, systemEmailRejectUnauthorized: { type: Boolean, default: true }, + dataRetentionDays: { type: Number, default: 90 }, }, { timestamps: true, diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index b74c03f19..ad7f17af8 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -5,8 +5,12 @@ import { verifyTeamPermission } from "@/middleware/VerifyPermission.js"; import { addUserContext } from "@/middleware/AddUserContext.js"; import { PERMISSIONS } from "@/services/business/AuthService.js"; import { validateBody } from "@/middleware/validation.js"; -import { systemSettingsSchema } from "@/validation/index.js"; +import { + systemSettingsSchema, + updateRetentionPolicySchema, +} from "@/validation/index.js"; import { verify } from "node:crypto"; +import { FeatureFlags } from "@/utils/FeatureFlags.js"; class SettingsRoutes { private router; @@ -34,6 +38,14 @@ class SettingsRoutes { validateBody(systemSettingsSchema), this.controller.updateEmailSettings ); + this.router.patch( + "/retention", + verifyToken, + addUserContext, + verifyTeamPermission(FeatureFlags.getSetRetentionPermission()), + validateBody(updateRetentionPolicySchema), + this.controller.updateRetentionPolicy + ); this.router.post( "/test-transport", verifyToken, diff --git a/server/src/services/system/SettingsService.ts b/server/src/services/system/SettingsService.ts index fe4bc933e..bcedfd378 100644 --- a/server/src/services/system/SettingsService.ts +++ b/server/src/services/system/SettingsService.ts @@ -1,4 +1,5 @@ import { ISystemSettings, SystemSettings } from "@/db/models/index.js"; +import mongoose from "mongoose"; import ApiError from "@/utils/ApiError.js"; import { EmailService } from "@/services/index.js"; const SERVICE_NAME = "SettingsService"; @@ -7,6 +8,7 @@ export interface ISettingsService { updateEmailSettings: ( settings: Partial ) => Promise; + updateRetentionPolicy: (retentionDays: number) => Promise; } class SettingsService implements ISettingsService { @@ -93,6 +95,59 @@ class SettingsService implements ISettingsService { return settings; }; + + updateRetentionPolicy = async (retentionDays: number) => { + const settings = await SystemSettings.findOneAndUpdate( + { _id: "global" }, + { $set: { checksRetentionDays: retentionDays } }, + { new: true, upsert: true, setDefaultsOnInsert: true } + ); + + if (!settings) { + throw new ApiError("Unable to update system settings", 500); + } + + const db = mongoose.connection.db; + if (!db) { + throw new ApiError("Database not initialized", 500); + } + + const seconds = Math.floor(retentionDays * 24 * 60 * 60); + + const cmdResult = await db.command({ + collMod: "checks", + expireAfterSeconds: seconds, + }); + + if (!cmdResult || (cmdResult as any).ok !== 1) { + throw new ApiError("Failed to apply TTL to checks collection", 500); + } + + const list = (await db.command({ + listCollections: 1, + filter: { name: "checks" }, + })) as unknown as { + cursor?: { + firstBatch?: Array<{ + options?: { expireAfterSeconds?: number | string }; + }>; + }; + ok?: 1 | 0; + }; + const expireOption = + list?.cursor?.firstBatch?.[0]?.options?.expireAfterSeconds; + const appliedSeconds = + typeof expireOption === "number" ? expireOption : Number(expireOption); + + if (!appliedSeconds || appliedSeconds !== seconds) { + throw new ApiError( + "TTL verification failed: expireAfterSeconds not applied", + 500 + ); + } + + return retentionDays; + }; } export default SettingsService; diff --git a/server/src/utils/FeatureFlags.ts b/server/src/utils/FeatureFlags.ts index 28ce2533c..c2775f6c9 100644 --- a/server/src/utils/FeatureFlags.ts +++ b/server/src/utils/FeatureFlags.ts @@ -11,8 +11,11 @@ const FeatureFlags = { mode: CURRENT_MODE, isSaaS, isSelfHosted, - getDiagnosticPermission(): string { - return isSaaS() ? PERMISSIONS.master : PERMISSIONS.diagnostic.read; + getDiagnosticPermission(): string[] { + return isSaaS() ? [PERMISSIONS.master] : [PERMISSIONS.diagnostic.read]; + }, + getSetRetentionPermission(): string[] { + return isSaaS() ? [PERMISSIONS.master] : [PERMISSIONS.checks.write]; }, }; diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index 4ba571e4e..a2d4b4f4c 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -646,3 +646,10 @@ export const systemSettingsSchema = z .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"), +});