import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { userPreferences } from '@/db/schema'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, withUserPreferencesMeta, type UserPreferencesMeta, } from '@/lib/server/user/preferences-payload'; export const dynamic = 'force-dynamic'; function serializePreferencesForDb(payload: Record): Record | string { if (process.env.POSTGRES_URL) return payload; return JSON.stringify(payload); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } interface PreferenceNormalizationContext { defaultProviderRef: string; showAllProviderModels: boolean; sharedProviders: Array<{ slug: string; providerType: TtsProviderId; defaultModel: string | null; defaultInstructions: string | null; }>; } async function loadPreferenceNormalizationContext(): Promise { const [runtimeConfig, providers] = await Promise.all([ getResolvedRuntimeConfig(), listAdminProviders(), ]); return { defaultProviderRef: runtimeConfig.defaultTtsProvider, showAllProviderModels: runtimeConfig.showAllProviderModels, sharedProviders: providers .filter((entry) => entry.enabled) .map((entry) => ({ slug: entry.slug, providerType: entry.providerType, defaultModel: entry.defaultModel, defaultInstructions: entry.defaultInstructions, })), }; } function parseStoredPreferences( value: unknown, context: PreferenceNormalizationContext, ): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } { const payload = deserializeUserPreferencesPayload(value); const meta = extractUserPreferencesMeta(payload); const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true }); return { ...sanitized, meta }; } function sanitizeSavedVoices(value: unknown): Record { if (!isRecord(value)) return {}; const out: Record = {}; for (const [key, val] of Object.entries(value)) { if (typeof key !== 'string' || key.length === 0) continue; if (typeof val !== 'string') continue; out[key] = val; } return out; } function sanitizePreferencesPatch( input: unknown, context: PreferenceNormalizationContext, options: { fillMissingProvider: boolean }, ): { patch: SyncedPreferencesPatch; migrated: boolean } { if (!isRecord(input)) return { patch: {}, migrated: false }; const rec = input as Record; const out: SyncedPreferencesPatch = {}; let migrated = false; const legacyProviderRef = typeof rec.ttsProvider === 'string' ? rec.ttsProvider : typeof rec.provider === 'string' ? rec.provider : ''; const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef; const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef); const providerDefaults = resolveProviderDefaults({ providerRef: normalizedProviderRef || context.defaultProviderRef, providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', sharedProviders: context.sharedProviders, fallbackProviderRef: context.defaultProviderRef, }); const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true; for (const key of SYNCED_PREFERENCE_KEYS) { if (!(key in rec)) continue; const value = rec[key]; switch (key) { case 'viewType': if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; break; case 'voice': case 'ttsModel': case 'ttsInstructions': if (typeof value === 'string') out[key] = value; break; case 'providerRef': out[key] = providerDefaults.providerRef; break; case 'providerType': out[key] = providerDefaults.providerType; break; case 'voiceSpeed': case 'audioPlayerSpeed': case 'segmentPreloadDepthPages': case 'segmentPreloadSentenceLookahead': case 'ttsSegmentMaxBlockLength': case 'headerMargin': case 'footerMargin': case 'leftMargin': case 'rightMargin': if (Number.isFinite(value)) out[key] = Number(value); break; case 'skipBlank': case 'epubTheme': case 'pdfHighlightEnabled': case 'pdfWordHighlightEnabled': case 'epubHighlightEnabled': case 'epubWordHighlightEnabled': case 'htmlHighlightEnabled': case 'htmlWordHighlightEnabled': if (typeof value === 'boolean') out[key] = value; break; case 'savedVoices': out[key] = sanitizeSavedVoices(value); break; default: break; } } if ('providerRef' in out && !('providerType' in out)) { out.providerType = providerDefaults.providerType; migrated = true; } if (options.fillMissingProvider && !('providerRef' in out)) { out.providerRef = providerDefaults.providerRef; migrated = true; } if (options.fillMissingProvider && !('providerType' in out)) { out.providerType = providerDefaults.providerType; migrated = true; } const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; const shouldNormalizeSharedDefaultModel = !!providerDefaults.defaultModel && context.sharedProviders.some((entry) => entry.slug === providerDefaults.providerRef) && (rawModel.length === 0 || rawModel === 'kokoro') && (hasLegacyProviderKey || rawProviderRef === 'default-openai'); if (options.fillMissingProvider && !('ttsModel' in out) && providerDefaults.defaultModel) { out.ttsModel = providerDefaults.defaultModel; migrated = true; } else if (shouldNormalizeSharedDefaultModel && out.ttsModel !== providerDefaults.defaultModel) { out.ttsModel = providerDefaults.defaultModel; migrated = true; } if (!context.showAllProviderModels && providerDefaults.defaultModel && out.ttsModel !== providerDefaults.defaultModel) { out.ttsModel = providerDefaults.defaultModel; migrated = true; } return { patch: out, migrated }; } function normalizeClientUpdatedAtMs(value: unknown): number { const normalized = coerceTimestampMs(value, nowTimestampMs()); if (normalized <= 0) return nowTimestampMs(); return normalized; } export async function GET(req: NextRequest) { try { const normalizationContext = await loadPreferenceNormalizationContext(); const scope = await resolveUserStateScope(req); if (scope instanceof Response) return scope; const rows = await db .select({ dataJson: userPreferences.dataJson, clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, }) .from(userPreferences) .where(eq(userPreferences.userId, scope.ownerUserId)) .limit(1); const row = rows[0]; const stored = parseStoredPreferences(row?.dataJson, normalizationContext); const storedPatch = stored.patch; const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta); const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); if (row && stored.migrated) { const updatedAt = nowTimestampMs(); await db .insert(userPreferences) .values({ userId: scope.ownerUserId, dataJson: serializePreferencesForDb(storedPayload), clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, updatedAt, }) .onConflictDoUpdate({ target: [userPreferences.userId], set: { dataJson: serializePreferencesForDb(storedPayload), updatedAt, }, }); } return NextResponse.json({ preferences: storedPatch, clientUpdatedAtMs, hasStoredPreferences: Boolean(row), }); } catch (error) { console.error('Error loading user preferences:', error); return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 }); } } export async function PUT(req: NextRequest) { try { const normalizationContext = await loadPreferenceNormalizationContext(); const scope = await resolveUserStateScope(req); if (scope instanceof Response) return scope; const body = (await req.json().catch(() => null)) as | { patch?: unknown; clientUpdatedAtMs?: unknown } | null; const patch = sanitizePreferencesPatch( body?.patch, normalizationContext, { fillMissingProvider: false }, ).patch; const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); if (Object.keys(patch).length === 0) { return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 }); } const existingRows = await db .select({ dataJson: userPreferences.dataJson, clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, }) .from(userPreferences) .where(eq(userPreferences.userId, scope.ownerUserId)) .limit(1); const existing = existingRows[0]; const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); const existingStored = parseStoredPreferences(existing?.dataJson, normalizationContext); const existingPatch = existingStored.patch; if (existing && clientUpdatedAtMs < existingUpdated) { return NextResponse.json({ preferences: existingPatch, clientUpdatedAtMs: existingUpdated, applied: false, }); } const mergedPatch = { ...existingPatch, ...patch }; const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta); const dataJson = serializePreferencesForDb(payloadWithMeta); const updatedAt = nowTimestampMs(); await db .insert(userPreferences) .values({ userId: scope.ownerUserId, dataJson, clientUpdatedAtMs, updatedAt, }) .onConflictDoUpdate({ target: [userPreferences.userId], set: { dataJson, clientUpdatedAtMs, updatedAt, }, }); return NextResponse.json({ preferences: mergedPatch, clientUpdatedAtMs, applied: true, }); } catch (error) { console.error('Error updating user preferences:', error); return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 }); } }