type SettingMetadata = { defaultValue: () => T; parseOverride: (raw: string | undefined) => T | null; serialize: (value: Input) => string | null; resolve: (args: { defaultValue: T; overrideValue: T | null }) => T; }; type SettingsConversionValueMap = { ukvisajobsMaxJobs: number; gradcrackerMaxJobsPerTerm: number; searchTerms: string[]; jobspyLocation: string; jobspyResultsWanted: number; jobspyHoursOld: number; jobspyCountryIndeed: string; jobspySites: string[]; jobspyLinkedinFetchDescription: boolean; jobspyIsRemote: boolean; showSponsorInfo: boolean; backupEnabled: boolean; backupHour: number; backupMaxCount: number; penalizeMissingSalary: boolean; missingSalaryPenalty: number; }; type SettingsConversionInputMap = { [K in keyof SettingsConversionValueMap]: | SettingsConversionValueMap[K] | null | undefined; }; type SettingsConversionMetadata = { [K in keyof SettingsConversionValueMap]: SettingMetadata< SettingsConversionValueMap[K], SettingsConversionInputMap[K] >; }; export type SettingsConversionKey = keyof SettingsConversionValueMap; function parseIntOrNull(raw: string | undefined): number | null { if (!raw) return null; const parsed = parseInt(raw, 10); return Number.isNaN(parsed) ? null : parsed; } function parseJsonArrayOrNull(raw: string | undefined): string[] | null { if (!raw) return null; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? (parsed as string[]) : null; } catch { return null; } } function parseBitBoolOrNull(raw: string | undefined): boolean | null { if (!raw) return null; return raw === "true" || raw === "1"; } function serializeNullableNumber( value: number | null | undefined, ): string | null { return value !== null && value !== undefined ? String(value) : null; } function serializeNullableJsonArray( value: string[] | null | undefined, ): string | null { return value !== null && value !== undefined ? JSON.stringify(value) : null; } function serializeBitBool(value: boolean | null | undefined): string | null { if (value === null || value === undefined) return null; return value ? "1" : "0"; } function resolveWithNullishFallback(args: { defaultValue: T; overrideValue: T | null; }): T { return args.overrideValue ?? args.defaultValue; } function resolveWithEmptyStringFallback(args: { defaultValue: string; overrideValue: string | null; }): string { return args.overrideValue || args.defaultValue; } export const settingsConversionMetadata: SettingsConversionMetadata = { ukvisajobsMaxJobs: { defaultValue: () => 50, parseOverride: parseIntOrNull, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, gradcrackerMaxJobsPerTerm: { defaultValue: () => 50, parseOverride: parseIntOrNull, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, searchTerms: { defaultValue: () => (process.env.JOBSPY_SEARCH_TERMS || "web developer") .split("|") .map((value) => value.trim()) .filter(Boolean), parseOverride: parseJsonArrayOrNull, serialize: serializeNullableJsonArray, resolve: resolveWithNullishFallback, }, jobspyLocation: { defaultValue: () => process.env.JOBSPY_LOCATION || "UK", parseOverride: (raw) => raw ?? null, serialize: (value) => value ?? null, resolve: resolveWithEmptyStringFallback, }, jobspyResultsWanted: { defaultValue: () => parseInt(process.env.JOBSPY_RESULTS_WANTED || "200", 10), parseOverride: parseIntOrNull, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, jobspyHoursOld: { defaultValue: () => parseInt(process.env.JOBSPY_HOURS_OLD || "72", 10), parseOverride: parseIntOrNull, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, jobspyCountryIndeed: { defaultValue: () => process.env.JOBSPY_COUNTRY_INDEED || "UK", parseOverride: (raw) => raw ?? null, serialize: (value) => value ?? null, resolve: resolveWithEmptyStringFallback, }, jobspySites: { defaultValue: () => (process.env.JOBSPY_SITES || "indeed,linkedin") .split(",") .map((value) => value.trim()) .filter(Boolean), parseOverride: parseJsonArrayOrNull, serialize: serializeNullableJsonArray, resolve: resolveWithNullishFallback, }, jobspyLinkedinFetchDescription: { defaultValue: () => (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || "1") === "1", parseOverride: parseBitBoolOrNull, serialize: serializeBitBool, resolve: resolveWithNullishFallback, }, jobspyIsRemote: { defaultValue: () => (process.env.JOBSPY_IS_REMOTE || "0") === "1", parseOverride: parseBitBoolOrNull, serialize: serializeBitBool, resolve: resolveWithNullishFallback, }, showSponsorInfo: { defaultValue: () => true, parseOverride: parseBitBoolOrNull, serialize: serializeBitBool, resolve: resolveWithNullishFallback, }, backupEnabled: { defaultValue: () => false, parseOverride: parseBitBoolOrNull, serialize: serializeBitBool, resolve: resolveWithNullishFallback, }, backupHour: { defaultValue: () => 2, parseOverride: (raw) => { const parsed = raw ? parseInt(raw, 10) : NaN; if (Number.isNaN(parsed)) return null; return Math.min(23, Math.max(0, parsed)); }, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, backupMaxCount: { defaultValue: () => 5, parseOverride: (raw) => { const parsed = raw ? parseInt(raw, 10) : NaN; if (Number.isNaN(parsed)) return null; return Math.min(5, Math.max(1, parsed)); }, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, penalizeMissingSalary: { defaultValue: () => (process.env.PENALIZE_MISSING_SALARY || "0") === "1" || (process.env.PENALIZE_MISSING_SALARY || "").toLowerCase() === "true", parseOverride: parseBitBoolOrNull, serialize: serializeBitBool, resolve: resolveWithNullishFallback, }, missingSalaryPenalty: { defaultValue: () => { const raw = process.env.MISSING_SALARY_PENALTY; if (!raw) return 10; const parsed = parseInt(raw, 10); if (Number.isNaN(parsed)) return 10; return Math.min(100, Math.max(0, parsed)); }, parseOverride: (raw) => { const parsed = raw ? parseInt(raw, 10) : NaN; if (Number.isNaN(parsed)) return null; return Math.min(100, Math.max(0, parsed)); }, serialize: serializeNullableNumber, resolve: resolveWithNullishFallback, }, }; export function resolveSettingValue( key: K, raw: string | undefined, ): { defaultValue: SettingsConversionValueMap[K]; overrideValue: SettingsConversionValueMap[K] | null; value: SettingsConversionValueMap[K]; } { const metadata = settingsConversionMetadata[key]; const defaultValue = metadata.defaultValue(); const overrideValue = metadata.parseOverride(raw); const value = metadata.resolve({ defaultValue, overrideValue, }); return { defaultValue, overrideValue, value }; } export function serializeSettingValue( key: K, value: SettingsConversionInputMap[K], ): string | null { const metadata = settingsConversionMetadata[key]; return metadata.serialize(value); }