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 { 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<string, unknown>): Record<string, unknown> | string {
if (process.env.POSTGRES_URL) return payload;
return JSON.stringify(payload);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -54,21 +60,11 @@ async function loadPreferenceNormalizationContext(): Promise<PreferenceNormaliza
function parseStoredPreferences(
value: unknown,
context: PreferenceNormalizationContext,
): { patch: SyncedPreferencesPatch; migrated: boolean } {
if (!value) return { patch: {}, migrated: false };
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
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 };
): { 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<string, string> {
@ -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

View file

@ -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<string | null>(null);
const changelogVersionCheckInFlightRef = useRef<string | null>(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<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(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
setCustomModelInput(modelValue);

View file

@ -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<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 = {
patch: SyncedPreferencesPatch;
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));
}
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