From beb854cb39b8b4dedf95afeb54bfc3d6996e54f4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 14 May 2026 10:53:38 -0600 Subject: [PATCH] feat(preferences): add changelog version tracking and payload meta support Integrate changelog version check on settings open, triggering the changelog panel when a new app version is detected. Refactor user preferences storage to support metadata via payload utilities, enabling version tracking and future extensibility. Add API endpoint for changelog version check and related tests for changelog and preferences payload logic. --- .../state/changelog/version-check/route.ts | 86 +++++++++++++++++++ src/app/api/user/state/preferences/route.ts | 43 +++++----- src/components/SettingsModal.tsx | 58 ++++++++++++- src/lib/client/api/user-state.ts | 25 ++++++ src/lib/server/user/preferences-payload.ts | 52 +++++++++++ src/lib/shared/changelog.ts | 10 +++ 6 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 src/app/api/user/state/changelog/version-check/route.ts create mode 100644 src/lib/server/user/preferences-payload.ts diff --git a/src/app/api/user/state/changelog/version-check/route.ts b/src/app/api/user/state/changelog/version-check/route.ts new file mode 100644 index 0000000..e4b4e0c --- /dev/null +++ b/src/app/api/user/state/changelog/version-check/route.ts @@ -0,0 +1,86 @@ +import { eq } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { userPreferences } from '@/db/schema'; +import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog'; +import { nowTimestampMs } from '@/lib/shared/timestamps'; +import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; +import { + deserializeUserPreferencesPayload, + extractUserPreferencesMeta, + withUserPreferencesMeta, + USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY, +} 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); +} + +export async function POST(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as + | { currentVersion?: unknown } + | null; + const currentVersion = normalizeVersion( + typeof body?.currentVersion === 'string' ? body.currentVersion : '', + ); + if (!currentVersion) { + return NextResponse.json({ error: 'currentVersion is required' }, { status: 400 }); + } + + 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 existingPayload = deserializeUserPreferencesPayload(row?.dataJson); + const existingMeta = extractUserPreferencesMeta(existingPayload); + const lastSeenVersion = typeof existingMeta.lastSeenAppVersion === 'string' + ? existingMeta.lastSeenAppVersion + : null; + const shouldOpen = shouldOpenChangelogForVersionChange(lastSeenVersion, currentVersion); + const nextMeta = { + ...existingMeta, + [USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]: currentVersion, + }; + const dataJson = serializePreferencesForDb(withUserPreferencesMeta(existingPayload, nextMeta)); + const updatedAt = nowTimestampMs(); + const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); + + await db + .insert(userPreferences) + .values({ + userId: scope.ownerUserId, + dataJson, + clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson, + updatedAt, + }, + }); + + return NextResponse.json({ + shouldOpen, + currentVersion, + lastSeenVersion, + }); + } catch (error) { + console.error('Error checking changelog version:', error); + return NextResponse.json({ error: 'Failed to check changelog version' }, { status: 500 }); + } +} diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index e60b5fb..7cc0c2f 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -9,12 +9,18 @@ import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider 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(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string { - if (process.env.POSTGRES_URL) return patch; - return JSON.stringify(patch); +function serializePreferencesForDb(payload: Record): Record | string { + if (process.env.POSTGRES_URL) return payload; + return JSON.stringify(payload); } function isRecord(value: unknown): value is Record { @@ -54,21 +60,11 @@ async function loadPreferenceNormalizationContext(): Promise { @@ -216,6 +212,7 @@ export async function GET(req: NextRequest) { 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) { @@ -224,14 +221,14 @@ export async function GET(req: NextRequest) { .insert(userPreferences) .values({ userId: scope.ownerUserId, - dataJson: serializePreferencesForDb(storedPatch), + dataJson: serializePreferencesForDb(storedPayload), clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, updatedAt, }) .onConflictDoUpdate({ target: [userPreferences.userId], set: { - dataJson: serializePreferencesForDb(storedPatch), + dataJson: serializePreferencesForDb(storedPayload), updatedAt, }, }); @@ -278,7 +275,8 @@ export async function PUT(req: NextRequest) { .limit(1); const existing = existingRows[0]; const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); - const existingPatch = parseStoredPreferences(existing?.dataJson, normalizationContext).patch; + const existingStored = parseStoredPreferences(existing?.dataJson, normalizationContext); + const existingPatch = existingStored.patch; if (existing && clientUpdatedAtMs < existingUpdated) { return NextResponse.json({ @@ -289,7 +287,8 @@ export async function PUT(req: NextRequest) { } const mergedPatch = { ...existingPatch, ...patch }; - const dataJson = serializePreferencesForDb(mergedPatch); + const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta); + const dataJson = serializePreferencesForDb(payloadWithMeta); const updatedAt = nowTimestampMs(); await db diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c9bc620..14d8e67 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; +import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Dialog, DialogPanel, @@ -33,6 +33,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; +import { postChangelogVersionCheck } from '@/lib/client/api/user-state'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; @@ -174,7 +175,9 @@ export function SettingsModal({ className = '' }: { className?: string }) { const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); - const { data: session } = useAuthSession(); + const { data: session, isPending: isSessionPending } = useAuthSession(); + const changelogVersionCheckKeyRef = useRef(null); + const changelogVersionCheckInFlightRef = useRef(null); const router = useRouter(); const isBusy = isImportingLibrary; const { @@ -249,6 +252,57 @@ export function SettingsModal({ className = '' }: { className?: string }) { }; }, [authEnabled, checkFirstVist]); + useEffect(() => { + let active = true; + let timer: ReturnType | null = null; + const run = async () => { + const sessionUserId = session?.user?.id ?? null; + if (authEnabled && (isSessionPending || !sessionUserId)) return; + + const currentVersion = normalizeVersion(runtimeConfig.appVersion || ''); + if (!currentVersion) return; + + const userKey = sessionUserId ?? 'server-unclaimed'; + const checkKey = `${userKey}:${currentVersion}`; + if (changelogVersionCheckKeyRef.current === checkKey) return; + if (changelogVersionCheckInFlightRef.current === checkKey) return; + changelogVersionCheckInFlightRef.current = checkKey; + + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const result = await postChangelogVersionCheck(currentVersion); + changelogVersionCheckKeyRef.current = checkKey; + if (result.shouldOpen && active) { + setIsOpen(true); + setIsChangelogOpen(true); + } + return; + } catch (error) { + if (attempt === 1) throw error; + await new Promise((resolve) => setTimeout(resolve, 400)); + } + } + } catch (error) { + console.warn('Failed to check changelog version:', error); + } finally { + if (changelogVersionCheckInFlightRef.current === checkKey) { + changelogVersionCheckInFlightRef.current = null; + } + } + }; + + // In React Strict Mode (dev), effects mount/unmount once before the real mount. + // Deferring the network mutation avoids writing lastSeenVersion from the throwaway pass. + timer = setTimeout(() => { + void run(); + }, 120); + return () => { + active = false; + if (timer) clearTimeout(timer); + }; + }, [authEnabled, isSessionPending, session?.user?.id, runtimeConfig.appVersion]); + useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { setCustomModelInput(modelValue); diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts index bf654a4..a3d0948 100644 --- a/src/lib/client/api/user-state.ts +++ b/src/lib/client/api/user-state.ts @@ -10,6 +10,12 @@ type ProgressResponse = { progress: DocumentProgressRecord | null; }; +type ChangelogVersionCheckResponse = { + shouldOpen: boolean; + currentVersion: string; + lastSeenVersion: string | null; +}; + function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch { const patch: SyncedPreferencesPatch = {}; for (const key of SYNCED_PREFERENCE_KEYS) { @@ -53,6 +59,25 @@ export async function putUserPreferences( return (await res.json()) as PreferencesResponse & { applied: boolean }; } +export async function postChangelogVersionCheck( + currentVersion: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch('/api/user/state/changelog/version-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentVersion }), + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to check changelog version'); + } + + return (await res.json()) as ChangelogVersionCheckResponse; +} + type PendingPreferenceSync = { patch: SyncedPreferencesPatch; timer: ReturnType | null; diff --git a/src/lib/server/user/preferences-payload.ts b/src/lib/server/user/preferences-payload.ts new file mode 100644 index 0000000..d6f750b --- /dev/null +++ b/src/lib/server/user/preferences-payload.ts @@ -0,0 +1,52 @@ +import { normalizeVersion } from '@/lib/shared/changelog'; + +export const USER_PREFERENCES_META_KEY = '_meta'; +export const USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY = 'lastSeenAppVersion'; + +export type UserPreferencesMeta = Record & { + lastSeenAppVersion?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function deserializeUserPreferencesPayload(value: unknown): Record { + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } + } + return isRecord(value) ? value : {}; +} + +export function extractUserPreferencesMeta(payload: unknown): UserPreferencesMeta { + const record = deserializeUserPreferencesPayload(payload); + const rawMeta = record[USER_PREFERENCES_META_KEY]; + if (!isRecord(rawMeta)) return {}; + + const out: UserPreferencesMeta = { ...rawMeta }; + const rawLastSeen = out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]; + if (typeof rawLastSeen === 'string') { + out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY] = normalizeVersion(rawLastSeen); + } else { + delete out[USER_PREFERENCES_LAST_SEEN_APP_VERSION_KEY]; + } + + return out; +} + +export function withUserPreferencesMeta( + payload: Record, + meta: UserPreferencesMeta, +): Record { + const out = { ...payload }; + delete out[USER_PREFERENCES_META_KEY]; + if (Object.keys(meta).length > 0) { + out[USER_PREFERENCES_META_KEY] = meta; + } + return out; +} diff --git a/src/lib/shared/changelog.ts b/src/lib/shared/changelog.ts index 09a4f65..9b71edb 100644 --- a/src/lib/shared/changelog.ts +++ b/src/lib/shared/changelog.ts @@ -38,6 +38,16 @@ export function findCurrentVersionIndex(entries: ChangelogManifestEntry[], appVe return entries.findIndex((entry) => tagsMatchVersion(entry.tag_name, appVersion)); } +export function shouldOpenChangelogForVersionChange( + lastSeenVersion: string | null | undefined, + currentVersion: string | null | undefined, +): boolean { + const normalizedCurrent = normalizeVersion(currentVersion ?? ''); + if (!normalizedCurrent) return false; + const normalizedLastSeen = normalizeVersion(lastSeenVersion ?? ''); + return normalizedLastSeen !== normalizedCurrent; +} + export function toSafeTagSlug(tagName: string): string { const normalized = tagName.trim().toLowerCase(); const collapsed = normalized