From 48e71d4345b698d6c78707b4ee2559a1d64c91b7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:53:31 -0700 Subject: [PATCH 1/3] use null for unsetting instead of empty string --- .../repositories/settings/ISettingsRepository.ts | 4 ++-- .../settings/MongoSettingsRepository.ts | 6 +++--- server/src/service/system/settingsService.ts | 6 +++--- server/src/types/settings.ts | 4 ++++ server/src/validation/settingsValidation.ts | 16 ++++++++-------- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/server/src/repositories/settings/ISettingsRepository.ts b/server/src/repositories/settings/ISettingsRepository.ts index 578d7f40d..0485a5aea 100644 --- a/server/src/repositories/settings/ISettingsRepository.ts +++ b/server/src/repositories/settings/ISettingsRepository.ts @@ -1,11 +1,11 @@ -import type { Settings } from "@/types/index.js"; +import type { Settings, SettingsUpdate } from "@/types/index.js"; export interface ISettingsRepository { // create create(settings: Partial): Promise; // fetch findSingleton(): Promise; // update - update(settings: Partial): Promise; + update(settings: SettingsUpdate): Promise; // delete deleteLegacy: () => Promise; } diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index f803ae6a9..e41abcd96 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -1,6 +1,6 @@ import mongoose from "mongoose"; import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; -import type { Settings } from "@/types/index.js"; +import type { Settings, SettingsUpdate } from "@/types/index.js"; import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js"; class MongoSettingsRepository implements ISettingsRepository { @@ -59,12 +59,12 @@ class MongoSettingsRepository implements ISettingsRepository { return this.toEntity(settings); }; - update = async (settings: Partial) => { + update = async (settings: SettingsUpdate) => { const update: Record = { $set: {}, $unset: {} }; // Iterate through settings and separate into $set and $unset Object.entries(settings).forEach(([key, value]) => { - if (value === undefined || value === "") { + if (value === undefined || value === null) { // Unset undefined or empty string values update.$unset[key] = ""; } else { diff --git a/server/src/service/system/settingsService.ts b/server/src/service/system/settingsService.ts index 10ac61725..519d2f390 100755 --- a/server/src/service/system/settingsService.ts +++ b/server/src/service/system/settingsService.ts @@ -1,5 +1,5 @@ import { ISettingsRepository } from "@/repositories/index.js"; -import { Settings } from "@/types/index.js"; +import { Settings, SettingsUpdate } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; import { ValidatedEnv } from "@/validation/envValidation.js"; import type { StringValue } from "ms"; @@ -19,7 +19,7 @@ export interface ISettingsService { loadSettings(): EnvConfig; getSettings(): EnvConfig; getDBSettings(): Promise; - updateDbSettings(newSettings: Partial): Promise; + updateDbSettings(newSettings: SettingsUpdate): Promise; } export class SettingsService implements ISettingsService { @@ -54,7 +54,7 @@ export class SettingsService implements ISettingsService { return this.settings; } - updateDbSettings = async (newSettings: Partial) => { + updateDbSettings = async (newSettings: SettingsUpdate) => { return await this.settingsRepository.update(newSettings); }; diff --git a/server/src/types/settings.ts b/server/src/types/settings.ts index 108cdcb72..405dc3efe 100644 --- a/server/src/types/settings.ts +++ b/server/src/types/settings.ts @@ -5,6 +5,10 @@ export interface SettingsThresholds { temperature?: number; } +export type SettingsUpdate = { + [K in keyof Settings]?: Settings[K] | null; +}; + export interface Settings { id: string; checkTTL: number; diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index b9551dd4e..f2c4e4c94 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -8,16 +8,16 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ checkTTL: z.number().int().min(1).max(CHECK_TTL_SENTINEL).optional(), - systemEmailPort: z.number().optional(), - pagespeedApiKey: z.string().optional(), + systemEmailPort: z.number().nullable().optional(), + pagespeedApiKey: z.string().nullable().optional(), language: z.string().optional(), timezone: z.string().optional(), - systemEmailHost: z.string().optional(), - systemEmailAddress: z.string().optional(), - systemEmailPassword: z.string().optional(), - systemEmailUser: z.string().optional(), - systemEmailConnectionHost: z.string().optional(), - systemEmailTLSServername: z.string().optional(), + systemEmailHost: z.string().nullable().optional(), + systemEmailAddress: z.string().nullable().optional(), + systemEmailPassword: z.string().nullable().optional(), + systemEmailUser: z.string().nullable().optional(), + systemEmailConnectionHost: z.string().nullable().optional(), + systemEmailTLSServername: z.string().nullable().optional(), showURL: z.boolean().optional(), systemEmailSecure: z.boolean().optional(), From d3605b0149fa787206ec337ae5e34b0c609a8dc0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 09:54:08 -0700 Subject: [PATCH 2/3] update client validation/pages --- client/src/Hooks/useSettingsForm.ts | 4 ++-- client/src/Pages/Settings/index.tsx | 19 +++---------------- client/src/Validation/settings.ts | 16 ++++++++-------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 3af8c229e..4b0768d79 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -1,14 +1,14 @@ import { useMemo } from "react"; import { settingsSchema } from "@/Validation/settings"; import type { Settings } from "@/Types/Settings"; -import type { SettingsFormData } from "@/Validation/settings"; +import type { SettingsFormInput } from "@/Validation/settings"; interface UseSettingsFormOptions { data?: Settings | null; } export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => { return useMemo(() => { - const defaults: SettingsFormData = { + const defaults: SettingsFormInput = { systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false, systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false, systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true, diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e51255157..302ce9ab2 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -20,7 +20,7 @@ import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi"; import { useToast } from "@/Hooks/UseToast"; import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; -import type { SettingsFormData } from "@/Validation/settings"; +import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import { useState } from "react"; import { Controller } from "react-hook-form"; import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; @@ -89,7 +89,7 @@ export const SettingsPage = () => { // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); - const form = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, mode: "onChange", @@ -256,20 +256,7 @@ export const SettingsPage = () => { delete (dataToSend as any).systemEmailPassword; } - // Convert undefined to empty string for backend unsetting - const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => { - const typedKey = key as keyof SettingsFormData; - if (value === undefined) { - (acc as any)[typedKey] = ""; - } else if (typeof value === "object" && value !== null) { - (acc as any)[typedKey] = value; - } else { - (acc as any)[typedKey] = value; - } - return acc; - }, {} as Partial); - - const result = await patch("/settings", processedData as SettingsFormData); + const result = await patch("/settings", dataToSend as SettingsFormData); if (result?.success) { // Update API key state from response diff --git a/client/src/Validation/settings.ts b/client/src/Validation/settings.ts index 975927d1c..cf122f69d 100644 --- a/client/src/Validation/settings.ts +++ b/client/src/Validation/settings.ts @@ -7,7 +7,7 @@ export const settingsSchema = z.object({ systemEmailRejectUnauthorized: z.boolean(), systemEmailConnectionHost: z .string() - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), @@ -19,31 +19,30 @@ export const settingsSchema = z.object({ .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), pagespeedApiKey: z .string() - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), systemEmailHost: z .string() .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), systemEmailPort: z.number().int().min(1).max(65535).optional(), systemEmailAddress: z - .string() .email("Please enter a valid email address") .or(z.literal("")) - .transform((val) => (val === "" ? undefined : val.toLowerCase().trim())) + .transform((val) => (val === "" ? null : val.toLowerCase().trim())) .optional(), systemEmailUser: z .string() - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), systemEmailPassword: z .string() - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), systemEmailTLSServername: z .string() - .transform((val) => (val.trim() === "" ? undefined : val.trim())) + .transform((val) => (val.trim() === "" ? null : val.trim())) .optional(), globalThresholds: z.object({ cpu: z.number().int().min(1).max(100), @@ -53,4 +52,5 @@ export const settingsSchema = z.object({ }), }); +export type SettingsFormInput = z.input; export type SettingsFormData = z.infer; From 81f623b632e0a7c6774e7659f588f139539cc0b4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 12 Mar 2026 10:05:36 -0700 Subject: [PATCH 3/3] properly type updates --- .../settings/MongoSettingsRepository.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/server/src/repositories/settings/MongoSettingsRepository.ts b/server/src/repositories/settings/MongoSettingsRepository.ts index e41abcd96..3117a94ea 100644 --- a/server/src/repositories/settings/MongoSettingsRepository.ts +++ b/server/src/repositories/settings/MongoSettingsRepository.ts @@ -1,4 +1,4 @@ -import mongoose from "mongoose"; +import mongoose, { type UpdateQuery } from "mongoose"; import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js"; import type { Settings, SettingsUpdate } from "@/types/index.js"; import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js"; @@ -60,26 +60,22 @@ class MongoSettingsRepository implements ISettingsRepository { }; update = async (settings: SettingsUpdate) => { - const update: Record = { $set: {}, $unset: {} }; + const $set: Record = {}; + const $unset: Record = {}; // Iterate through settings and separate into $set and $unset Object.entries(settings).forEach(([key, value]) => { if (value === undefined || value === null) { - // Unset undefined or empty string values - update.$unset[key] = ""; + $unset[key] = ""; } else { - // Set defined values - update.$set[key] = value; + $set[key] = value; } }); - // Clean up empty operators - if (Object.keys(update.$set).length === 0) { - delete update.$set; - } - if (Object.keys(update.$unset).length === 0) { - delete update.$unset; - } + const update: UpdateQuery = { + ...(Object.keys($set).length > 0 && { $set }), + ...(Object.keys($unset).length > 0 && { $unset }), + }; await AppSettingsModel.findOneAndUpdate({}, update, { upsert: true,