diff --git a/app/api/history/[id]/route.ts b/app/api/history/[id]/route.ts index f544703..5ebf149 100644 --- a/app/api/history/[id]/route.ts +++ b/app/api/history/[id]/route.ts @@ -3,6 +3,47 @@ import { query } from '@/lib/db'; import logger from '@/lib/log'; import { getRequestId, jsonResponse, PRIVATE_NO_STORE } from '@/lib/request'; +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const requestId = getRequestId(req); + const start = Date.now(); + + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, durationMs: Date.now() - start }, 'history.get.unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const { id } = await params; + if (!/^[a-z0-9-]{8,80}$/i.test(id)) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start }, 'history.get.invalid'); + return jsonResponse({ error: 'Invalid id' }, { status: 400 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const result = await query( + 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE id = $1 AND user_email = $2 LIMIT 1', + [id, session.user.email], + ); + const row = result.rows[0]; + if (!row) { + logger.warn({ requestId, user: session.user.email, id, durationMs: Date.now() - start }, 'history.get.missing'); + return jsonResponse({ error: 'Not found' }, { status: 404 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + logger.info({ + requestId, + user: session.user.email, + id, + durationMs: Date.now() - start, + updatedAt: row.updated_at, + }, 'history.get'); + return jsonResponse(row, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'history.get.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } +} + export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { const requestId = getRequestId(req); const start = Date.now(); diff --git a/app/page.tsx b/app/page.tsx index 33f126c..4af9797 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -32,6 +32,11 @@ const GIRL_MODE_DEFAULT_SYSTEM_PROMPT = [ ].join(' '); type HistoryItem = { id: string; title: string; updatedAt: string; messages: Message[]; model: string; systemPrompt: string }; +type HistoryRow = { id: string; iv: string; ciphertext: string; updated_at: string }; +type HistoryRestoreResult = + | { kind: 'ok'; item: HistoryItem } + | { kind: 'missing' } + | { kind: 'error' }; type SettingsPatch = { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; keyJwk?: string | null }; type PendingSettings = Omit; @@ -120,6 +125,13 @@ function parseClientError(error: unknown): string { return '[ERROR] Something went wrong while sending that message. Try again.'; } +function getClientErrorDetails(error: unknown): { name: string | null; message: string } { + return { + name: error instanceof Error ? error.name : null, + message: error instanceof Error ? error.message : String(error), + }; +} + function readImageFiles(files: File[], onEach: (img: Image) => void) { files.forEach(file => { const reader = new FileReader(); @@ -362,7 +374,7 @@ export default function Home() { }); return null; } - const rows = await res.json() as { id: string; iv: string; ciphertext: string; updated_at: string }[]; + const rows = await res.json() as HistoryRow[]; const ordered = Array(rows.length).fill(null); let failed = 0; let firstFailedId: string | null = null; @@ -379,6 +391,48 @@ export default function Home() { return ordered.filter((item): item is HistoryItem => Boolean(item)); }; + const fetchHistoryItem = async (id: string): Promise => { + const keyTimeout = new Promise(resolve => setTimeout(resolve, 5000)); + await Promise.race([keyReadyRef.current, keyTimeout]); + const key = cryptoKeyRef.current; + if (!key) { + logClientEvent('history.key_unavailable', 'error', { id }); + return { kind: 'error' }; + } + try { + const res = await fetch(`/api/history/${encodeURIComponent(id)}`); + if (res.status === 404) { + logClientEvent('history.restore_missing', 'warn', { + id, + requestId: res.headers.get('x-request-id'), + }); + return { kind: 'missing' }; + } + if (!res.ok) { + logClientEvent('history.restore_fetch_failed', 'warn', { + id, + status: res.status, + requestId: res.headers.get('x-request-id'), + }); + return { kind: 'error' }; + } + const row = await res.json() as HistoryRow; + try { + const data = await decrypt<{ messages: Message[]; model: string; systemPrompt: string; title: string }>(key, row.iv, row.ciphertext); + return { kind: 'ok', item: { id: row.id, updatedAt: row.updated_at, ...data } }; + } catch { + logClientEvent('history.restore_decrypt_failed', 'warn', { id }); + return { kind: 'error' }; + } + } catch (error) { + logClientEvent('history.restore_fetch_failed', 'error', { + id, + ...getClientErrorDetails(error), + }); + return { kind: 'error' }; + } + }; + useEffect(() => { const saved = localStorage.getItem(MODEL_KEY); if (saved) setModel(saved); @@ -443,18 +497,20 @@ export default function Home() { persistSettings({ keyJwk: jwk }, true); } if (!restoredFork && activeHistoryChatId) { - const items = await fetchHistoryItems(); - if (!items) return; - setHistoryItems(items); - const activeItem = items.find(item => item.id === activeHistoryChatId); - if (activeItem) applyLoadedChat(activeItem); - else rememberActiveHistoryChat(null); + const restored = await fetchHistoryItem(activeHistoryChatId); + if (restored.kind === 'ok') applyLoadedChat(restored.item); + else if (restored.kind === 'missing') rememberActiveHistoryChat(null); } }) - .catch(e => { - if (e.name !== 'AbortError') { + .catch(error => { + const { name, message } = getClientErrorDetails(error); + if (name !== 'AbortError') { initialSettingsLoadedRef.current = true; - logClientEvent('settings.load_failed', 'error'); + logClientEvent('settings.load_failed', 'error', { + name, + message, + activeHistoryChatId, + }); keyResolveRef.current?.(); } }); @@ -571,8 +627,8 @@ export default function Home() { const items = await fetchHistoryItems(); if (!items) return; setHistoryItems(items); - } catch { - logClientEvent('history.load_failed', 'error'); + } catch (error) { + logClientEvent('history.load_failed', 'error', getClientErrorDetails(error)); setHistoryItems([]); } finally { setHistoryLoading(false); diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 76e9179..c349509 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -374,6 +374,7 @@ test('followup UI wiring hides XML in assistant messages and submits the selecte test('history-loaded chats persist across refreshes and clear correctly', () => { const source = readFileSync(join(import.meta.dirname, '../app/page.tsx'), 'utf8'); + const historyItemRouteSource = readFileSync(join(import.meta.dirname, '../app/api/history/[id]/route.ts'), 'utf8'); assert.ok( source.includes("const ACTIVE_HISTORY_CHAT_KEY = 'gippidy-active-history-chat';"), 'page should define a dedicated localStorage key for the active saved chat', @@ -387,11 +388,11 @@ test('history-loaded chats persist across refreshes and clear correctly', () => 'history restore should only run when a forked chat is not taking over startup', ); assert.ok( - source.includes('const activeItem = items.find(item => item.id === activeHistoryChatId);'), - 'page should look up the saved chat that was active before refresh', + source.includes('const restored = await fetchHistoryItem(activeHistoryChatId);'), + 'page should restore the active saved chat by directly fetching that history row', ); assert.ok( - source.includes('if (activeItem) applyLoadedChat(activeItem);'), + source.includes('if (restored.kind === \'ok\') applyLoadedChat(restored.item);'), 'page should rehydrate the previously active saved chat after refresh', ); assert.ok( @@ -402,6 +403,18 @@ test('history-loaded chats persist across refreshes and clear correctly', () => source.includes('rememberActiveHistoryChat(null);'), 'clearing, deleting, or forking away from a saved chat should clear the persisted selection', ); + assert.ok( + source.includes("logClientEvent('history.restore_fetch_failed'"), + 'page should log a specific restore failure instead of only a generic settings load failure', + ); + assert.ok( + historyItemRouteSource.includes('SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE id = $1 AND user_email = $2 LIMIT 1'), + 'history item route should support fetching one saved chat by id for refresh restore', + ); + assert.ok( + historyItemRouteSource.includes("}, 'history.get');"), + 'history item route should log successful direct history fetches', + ); }); // ── settings validation ────────────────────────────────────────────────────────