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