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:
parent
5efb29cda9
commit
beb854cb39
6 changed files with 250 additions and 24 deletions
86
src/app/api/user/state/changelog/version-check/route.ts
Normal file
86
src/app/api/user/state/changelog/version-check/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
52
src/lib/server/user/preferences-payload.ts
Normal file
52
src/lib/server/user/preferences-payload.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue