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.
This commit is contained in:
Richard R 2026-05-14 10:53:38 -06:00
parent 5efb29cda9
commit beb854cb39
6 changed files with 250 additions and 24 deletions

View file

@ -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<string, unknown>): Record<string, unknown> | 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 });
}
}

View file

@ -9,12 +9,18 @@ import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider
import { listAdminProviders } from '@/lib/server/admin/providers'; import { listAdminProviders } from '@/lib/server/admin/providers';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; 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'; export const dynamic = 'force-dynamic';
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string { function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
if (process.env.POSTGRES_URL) return patch; if (process.env.POSTGRES_URL) return payload;
return JSON.stringify(patch); return JSON.stringify(payload);
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@ -54,21 +60,11 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
function parseStoredPreferences( function parseStoredPreferences(
value: unknown, value: unknown,
context: PreferenceNormalizationContext, context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean } { ): { patch: SyncedPreferencesPatch; migrated: boolean; meta: UserPreferencesMeta } {
if (!value) return { patch: {}, migrated: false }; const payload = deserializeUserPreferencesPayload(value);
if (typeof value === 'string') { const meta = extractUserPreferencesMeta(payload);
try { const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
const parsed = JSON.parse(value); return { ...sanitized, meta };
return isRecord(parsed)
? sanitizePreferencesPatch(parsed, context, { fillMissingProvider: true })
: { patch: {}, migrated: false };
} catch {
return { patch: {}, migrated: false };
}
}
return isRecord(value)
? sanitizePreferencesPatch(value, context, { fillMissingProvider: true })
: { patch: {}, migrated: false };
} }
function sanitizeSavedVoices(value: unknown): Record<string, string> { function sanitizeSavedVoices(value: unknown): Record<string, string> {
@ -216,6 +212,7 @@ export async function GET(req: NextRequest) {
const row = rows[0]; const row = rows[0];
const stored = parseStoredPreferences(row?.dataJson, normalizationContext); const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
const storedPatch = stored.patch; const storedPatch = stored.patch;
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
if (row && stored.migrated) { if (row && stored.migrated) {
@ -224,14 +221,14 @@ export async function GET(req: NextRequest) {
.insert(userPreferences) .insert(userPreferences)
.values({ .values({
userId: scope.ownerUserId, userId: scope.ownerUserId,
dataJson: serializePreferencesForDb(storedPatch), dataJson: serializePreferencesForDb(storedPayload),
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
updatedAt, updatedAt,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [userPreferences.userId], target: [userPreferences.userId],
set: { set: {
dataJson: serializePreferencesForDb(storedPatch), dataJson: serializePreferencesForDb(storedPayload),
updatedAt, updatedAt,
}, },
}); });
@ -278,7 +275,8 @@ export async function PUT(req: NextRequest) {
.limit(1); .limit(1);
const existing = existingRows[0]; const existing = existingRows[0];
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 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) { if (existing && clientUpdatedAtMs < existingUpdated) {
return NextResponse.json({ return NextResponse.json({
@ -289,7 +287,8 @@ export async function PUT(req: NextRequest) {
} }
const mergedPatch = { ...existingPatch, ...patch }; const mergedPatch = { ...existingPatch, ...patch };
const dataJson = serializePreferencesForDb(mergedPatch); const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
const dataJson = serializePreferencesForDb(payloadWithMeta);
const updatedAt = nowTimestampMs(); const updatedAt = nowTimestampMs();
await db await db

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Fragment, useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { import {
Dialog, Dialog,
DialogPanel, DialogPanel,
@ -33,6 +33,7 @@ import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; 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 { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
@ -174,7 +175,9 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession(); const { data: session, isPending: isSessionPending } = useAuthSession();
const changelogVersionCheckKeyRef = useRef<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(null);
const router = useRouter(); const router = useRouter();
const isBusy = isImportingLibrary; const isBusy = isImportingLibrary;
const { const {
@ -249,6 +252,57 @@ export function SettingsModal({ className = '' }: { className?: string }) {
}; };
}, [authEnabled, checkFirstVist]); }, [authEnabled, checkFirstVist]);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout> | 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(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
setCustomModelInput(modelValue); setCustomModelInput(modelValue);

View file

@ -10,6 +10,12 @@ type ProgressResponse = {
progress: DocumentProgressRecord | null; progress: DocumentProgressRecord | null;
}; };
type ChangelogVersionCheckResponse = {
shouldOpen: boolean;
currentVersion: string;
lastSeenVersion: string | null;
};
function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch { function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch {
const patch: SyncedPreferencesPatch = {}; const patch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) { for (const key of SYNCED_PREFERENCE_KEYS) {
@ -53,6 +59,25 @@ export async function putUserPreferences(
return (await res.json()) as PreferencesResponse & { applied: boolean }; return (await res.json()) as PreferencesResponse & { applied: boolean };
} }
export async function postChangelogVersionCheck(
currentVersion: string,
options?: { signal?: AbortSignal },
): Promise<ChangelogVersionCheckResponse> {
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 = { type PendingPreferenceSync = {
patch: SyncedPreferencesPatch; patch: SyncedPreferencesPatch;
timer: ReturnType<typeof setTimeout> | null; timer: ReturnType<typeof setTimeout> | null;

View file

@ -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<string, unknown> & {
lastSeenAppVersion?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function deserializeUserPreferencesPayload(value: unknown): Record<string, unknown> {
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<string, unknown>,
meta: UserPreferencesMeta,
): Record<string, unknown> {
const out = { ...payload };
delete out[USER_PREFERENCES_META_KEY];
if (Object.keys(meta).length > 0) {
out[USER_PREFERENCES_META_KEY] = meta;
}
return out;
}

View file

@ -38,6 +38,16 @@ export function findCurrentVersionIndex(entries: ChangelogManifestEntry[], appVe
return entries.findIndex((entry) => tagsMatchVersion(entry.tag_name, appVersion)); 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 { export function toSafeTagSlug(tagName: string): string {
const normalized = tagName.trim().toLowerCase(); const normalized = tagName.trim().toLowerCase();
const collapsed = normalized const collapsed = normalized