Jobber/shared/src/settings-registry.ts
Shaheer Sarfaraz b18c2eccbb
Code cleanup (#218)
* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep

* chore: configure knip with workspace entry points for all packages

* refactor(shared): split 1119-line types.ts into domain modules under types/

* refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly

* refactor(settings): migrate 4 manually-resolved settings into conversion registry

* refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator

* refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage

Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by
extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline
control logic into usePipelineControls.ts. Net negative line count across all files.

* feat: create settings registry (Step 1)

Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object.

* feat: derive schema, keys, and types from settings registry (Step 2)

Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry.

* refactor: gut envSettings and remove settings-conversion (Step 3)

Replaces manual env arrays with registry-driven maps in envSettings.ts.
Deletes settings-conversion.ts since all parsing/defaults now live in the registry.

* refactor: simplify getEffectiveSettings with generic loop (Step 4)

Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived.

* refactor: simplify settingsUpdateRegistry (Step 5)

Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects.

* refactor(settings): implement nested settings registry and clean up tests

- Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`)
- Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`)
- Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic
- Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline

* refactor(settings): address PR comments on env data and registry parsing

- Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries
- Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic)
- Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
2026-02-21 03:07:51 +00:00

424 lines
12 KiB
TypeScript

import { z } from "zod";
import type { ResumeProjectsSettings } from "./types/settings";
function parseNonEmptyStringOrNull(raw: string | undefined): string | null {
return raw === undefined || raw === "" ? null : raw;
}
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";
}
export const resumeProjectsSchema = z.object({
maxProjects: z.number().int().min(0).max(100),
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
});
export const settingsRegistry = {
// --- Typed Settings ---
model: {
kind: "typed" as const,
schema: z.string().trim().max(200),
default: (): string =>
typeof process !== "undefined"
? process.env.MODEL || "google/gemini-3-flash-preview"
: "google/gemini-3-flash-preview",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
llmProvider: {
kind: "typed" as const,
envKey: "LLM_PROVIDER",
schema: z.preprocess(
(v) => (v === "" ? null : v),
z
.enum(["openrouter", "lmstudio", "ollama", "openai", "gemini"])
.nullable(),
),
default: (): string =>
typeof process !== "undefined"
? process.env.LLM_PROVIDER || "openrouter"
: "openrouter",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
llmBaseUrl: {
kind: "typed" as const,
envKey: "LLM_BASE_URL",
schema: z.preprocess(
(v) => (v === "" ? null : v),
z.string().trim().url().max(2000).nullable(),
),
default: (): string =>
typeof process !== "undefined" ? process.env.LLM_BASE_URL || "" : "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
pipelineWebhookUrl: {
kind: "typed" as const,
schema: z.string().trim().max(2000),
default: (): string =>
typeof process !== "undefined"
? process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
jobCompleteWebhookUrl: {
kind: "typed" as const,
schema: z.string().trim().max(2000),
default: (): string =>
typeof process !== "undefined"
? process.env.JOB_COMPLETE_WEBHOOK_URL || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
resumeProjects: {
kind: "typed" as const,
schema: resumeProjectsSchema,
default: (): ResumeProjectsSettings => ({
maxProjects: 20,
lockedProjectIds: [],
aiSelectableProjectIds: [],
}),
parse: (raw: string | undefined): ResumeProjectsSettings | null => {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
serialize: (
value: ResumeProjectsSettings | null | undefined,
): string | null => {
return value ? JSON.stringify(value) : null;
},
},
ukvisajobsMaxJobs: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number => 50,
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
adzunaMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number =>
parseInt(
typeof process !== "undefined"
? process.env.ADZUNA_MAX_JOBS_PER_TERM || "50"
: "50",
10,
),
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
gradcrackerMaxJobsPerTerm: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number => 50,
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
searchTerms: {
kind: "typed" as const,
schema: z.array(z.string().trim().min(1).max(200)).max(100),
default: (): string[] =>
(typeof process !== "undefined"
? process.env.JOBSPY_SEARCH_TERMS || "web developer"
: "web developer"
)
.split("|")
.map((v) => v.trim())
.filter(Boolean),
parse: parseJsonArrayOrNull,
serialize: serializeNullableJsonArray,
},
searchCities: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.SEARCH_CITIES || process.env.JOBSPY_LOCATION || "UK"
: "UK",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
jobspyResultsWanted: {
kind: "typed" as const,
schema: z.number().int().min(1).max(1000),
default: (): number =>
parseInt(
typeof process !== "undefined"
? process.env.JOBSPY_RESULTS_WANTED || "200"
: "200",
10,
),
parse: parseIntOrNull,
serialize: serializeNullableNumber,
},
jobspyCountryIndeed: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.JOBSPY_COUNTRY_INDEED || "UK"
: "UK",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
showSponsorInfo: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => true,
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
chatStyleTone: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_TONE || "professional"
: "professional",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleFormality: {
kind: "typed" as const,
schema: z.string().trim().max(100),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_FORMALITY || "medium"
: "medium",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleConstraints: {
kind: "typed" as const,
schema: z.string().trim().max(4000),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_CONSTRAINTS || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
chatStyleDoNotUse: {
kind: "typed" as const,
schema: z.string().trim().max(1000),
default: (): string =>
typeof process !== "undefined"
? process.env.CHAT_STYLE_DO_NOT_USE || ""
: "",
parse: parseNonEmptyStringOrNull,
serialize: (value: string | null | undefined): string | null =>
value ?? null,
},
backupEnabled: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => false,
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
backupHour: {
kind: "typed" as const,
schema: z.number().int().min(0).max(23),
default: (): number => 2,
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(23, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
},
backupMaxCount: {
kind: "typed" as const,
schema: z.number().int().min(1).max(5),
default: (): number => 5,
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
if (Number.isNaN(parsed)) return null;
return Math.min(5, Math.max(1, parsed));
},
serialize: serializeNullableNumber,
},
penalizeMissingSalary: {
kind: "typed" as const,
schema: z.boolean(),
default: (): boolean => {
if (typeof process === "undefined") return false;
const v = process.env.PENALIZE_MISSING_SALARY || "0";
return v === "1" || v.toLowerCase() === "true";
},
parse: parseBitBoolOrNull,
serialize: serializeBitBool,
},
missingSalaryPenalty: {
kind: "typed" as const,
schema: z.number().int().min(0).max(100),
default: (): number => {
if (typeof process === "undefined") return 10;
const raw = process.env.MISSING_SALARY_PENALTY;
if (!raw) return 10;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? 10 : Math.min(100, Math.max(0, parsed));
},
parse: (raw: string | undefined): number | null => {
const parsed = raw ? parseInt(raw, 10) : NaN;
return Number.isNaN(parsed) ? null : Math.min(100, Math.max(0, parsed));
},
serialize: serializeNullableNumber,
},
autoSkipScoreThreshold: {
kind: "typed" as const,
schema: z.number().int().min(0).max(100),
default: (): number | null => null,
parse: (raw: string | undefined): number | null => {
if (!raw || raw === "null" || raw === "") return null;
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : Math.min(100, Math.max(0, parsed));
},
serialize: (value: number | null | undefined): string | null => {
return value === null || value === undefined ? null : String(value);
},
},
// --- Model Variants ---
modelScorer: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
modelTailoring: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
modelProjectSelection: {
kind: "model" as const,
schema: z.string().trim().max(200),
},
// --- Simple Strings ---
rxresumeBaseResumeId: {
kind: "string" as const,
schema: z.string().trim().max(200),
},
rxresumeEmail: {
kind: "string" as const,
envKey: "RXRESUME_EMAIL",
schema: z.string().trim().max(200),
},
ukvisajobsEmail: {
kind: "string" as const,
envKey: "UKVISAJOBS_EMAIL",
schema: z.string().trim().max(200),
},
adzunaAppId: {
kind: "string" as const,
envKey: "ADZUNA_APP_ID",
schema: z.string().trim().max(200),
},
basicAuthUser: {
kind: "string" as const,
envKey: "BASIC_AUTH_USER",
schema: z.string().trim().max(200),
},
// --- Secrets ---
llmApiKey: {
kind: "secret" as const,
envKey: "LLM_API_KEY",
schema: z.string().trim().max(2000),
},
rxresumePassword: {
kind: "secret" as const,
envKey: "RXRESUME_PASSWORD",
schema: z.string().trim().max(2000),
},
ukvisajobsPassword: {
kind: "secret" as const,
envKey: "UKVISAJOBS_PASSWORD",
schema: z.string().trim().max(2000),
},
adzunaAppKey: {
kind: "secret" as const,
envKey: "ADZUNA_APP_KEY",
schema: z.string().trim().max(2000),
},
basicAuthPassword: {
kind: "secret" as const,
envKey: "BASIC_AUTH_PASSWORD",
schema: z.string().trim().max(2000),
},
webhookSecret: {
kind: "secret" as const,
envKey: "WEBHOOK_SECRET",
schema: z.string().trim().max(2000),
},
// --- Aliases ---
jobspyLocation: {
kind: "alias" as const,
schema: z.string().trim().max(100),
target: "searchCities" as const,
},
// --- Virtual ---
enableBasicAuth: {
kind: "virtual" as const,
schema: z.boolean(),
},
} as const;
export type SettingsRegistry = typeof settingsRegistry;
export type SettingsRegistryKey = keyof SettingsRegistry;