import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { createCurrentUiPreferenceRepository, createCurrentUserRepository, } from "../repositories/factory.js"; import { defaultUiPreferences, sanitizeUiPreferences, UI_ONBOARDING_VERSION, type UiPreferences, } from "../../../types/ui-preferences.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); /** Accounts younger than this are treated as new and get onboarding. */ const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000; /** * Existing users must never be ambushed by onboarding. A user with no * ui_preferences row who registered more than a day ago predates this feature, * so they are handed a completed onboarding state. This is a read-time default * rather than a migration write: nothing is persisted until the user actually * changes something, so it stays correct on a fresh database too. */ function withOnboardingBackfill( preferences: UiPreferences, registeredAt: string | null | undefined, ): UiPreferences { const registeredMs = registeredAt ? Date.parse(registeredAt) : Number.NaN; const isNewAccount = Number.isFinite(registeredMs) && Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS; if (isNewAccount) return preferences; return { ...preferences, onboarding: { ...preferences.onboarding, completedVersion: UI_ONBOARDING_VERSION, }, }; } /** * @openapi * /ui-preferences: * get: * summary: Get the UI complexity preferences for the current user * description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow. * tags: * - UI Preferences * responses: * 200: * description: The current user's UI preferences. */ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { const existing = await createCurrentUiPreferenceRepository().findByUserId(userId); if (existing) { return res.json({ preferences: sanitizeUiPreferences(JSON.parse(existing.data)), }); } const user = await createCurrentUserRepository().findById(userId); return res.json({ preferences: withOnboardingBackfill( defaultUiPreferences(), user?.registeredAt, ), }); } catch (e) { databaseLogger.error("Failed to get UI preferences", e, { operation: "get_ui_preferences", userId, }); return res.status(500).json({ error: "Failed to get UI preferences" }); } }); /** * @openapi * /ui-preferences: * put: * summary: Update the UI complexity preferences for the current user * description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them. * tags: * - UI Preferences * requestBody: * required: true * content: * application/json: * schema: * type: object * responses: * 200: * description: Preferences updated successfully. * 400: * description: Invalid preferences payload. */ router.put("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!req.body || typeof req.body !== "object") { return res.status(400).json({ error: "Invalid preferences payload" }); } try { const repository = createCurrentUiPreferenceRepository(); const existing = await repository.findByUserId(userId); const base = existing ? sanitizeUiPreferences(JSON.parse(existing.data)) : defaultUiPreferences(); const body = req.body as Record; // Two levels of merging, with null meaning "clear this". A plain spread // could not express clearing an override, which the settings UI needs to // hand a knob back to the preset. const mergedOverrides: Record> = { ...(base.overrides as Record>), }; if (body.overrides === null) { for (const area of Object.keys(mergedOverrides)) { delete mergedOverrides[area]; } } else if (body.overrides && typeof body.overrides === "object") { for (const [area, patch] of Object.entries( body.overrides as Record, )) { if (patch === null) { delete mergedOverrides[area]; continue; } if (!patch || typeof patch !== "object") continue; const next = { ...(mergedOverrides[area] ?? {}) }; for (const [key, value] of Object.entries( patch as Record, )) { if (value === null) delete next[key]; else next[key] = value; } mergedOverrides[area] = next; } } const merged = sanitizeUiPreferences({ ...base, ...body, // Must come after the body spread, or a raw overrides payload would // clobber the merge above. overrides: mergedOverrides, onboarding: { ...base.onboarding, ...((body.onboarding as Record) ?? {}), }, }); await repository.upsert(userId, JSON.stringify(merged)); return res.json({ success: true, preferences: merged }); } catch (e) { databaseLogger.error("Failed to update UI preferences", e, { operation: "update_ui_preferences", userId, }); return res.status(500).json({ error: "Failed to update UI preferences" }); } }); export default router;