Merge pull request #3415 from bluewave-labs/fix/unset-with-explicit-null

fix: unset with explicit null
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-03-12 10:12:40 -07:00
commit 22f917034d
8 files changed
+42 -55

No files matched your search

+2 -2
View File
@@ -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,
+3 -16
View File
@@ -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<SettingsFormData>({
const form = useForm<SettingsFormInput, unknown, SettingsFormData>({
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<SettingsFormData>);
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
+8 -8
View File
@@ -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<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
@@ -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<Settings>): Promise<Settings>;
// fetch
findSingleton(): Promise<Settings | null>;
// update
update(settings: Partial<Settings>): Promise<Settings>;
update(settings: SettingsUpdate): Promise<Settings>;
// delete
deleteLegacy: () => Promise<boolean>;
}
@@ -1,6 +1,6 @@
import mongoose from "mongoose";
import mongoose, { type UpdateQuery } 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,27 +59,23 @@ class MongoSettingsRepository implements ISettingsRepository {
return this.toEntity(settings);
};
update = async (settings: Partial<Settings>) => {
const update: Record<string, any> = { $set: {}, $unset: {} };
update = async (settings: SettingsUpdate) => {
const $set: Record<string, unknown> = {};
const $unset: Record<string, string> = {};
// Iterate through settings and separate into $set and $unset
Object.entries(settings).forEach(([key, value]) => {
if (value === undefined || value === "") {
// Unset undefined or empty string values
update.$unset[key] = "";
if (value === undefined || value === null) {
$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<AppSettingsDocument> = {
...(Object.keys($set).length > 0 && { $set }),
...(Object.keys($unset).length > 0 && { $unset }),
};
await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
+3 -3
View File
@@ -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<Settings>;
updateDbSettings(newSettings: Partial<Settings>): Promise<Settings>;
updateDbSettings(newSettings: SettingsUpdate): Promise<Settings>;
}
export class SettingsService implements ISettingsService {
@@ -54,7 +54,7 @@ export class SettingsService implements ISettingsService {
return this.settings;
}
updateDbSettings = async (newSettings: Partial<Settings>) => {
updateDbSettings = async (newSettings: SettingsUpdate) => {
return await this.settingsRepository.update(newSettings);
};
+4
View File
@@ -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;
+8 -8
View File
@@ -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(),