diff --git a/songbird-dashboard/src/api/analytics.test.ts b/songbird-dashboard/src/api/analytics.test.ts index 9792774..9f5bb37 100644 --- a/songbird-dashboard/src/api/analytics.test.ts +++ b/songbird-dashboard/src/api/analytics.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('aws-amplify/auth', () => ({ - fetchAuthSession: vi.fn(), -})); - vi.mock('./client', () => ({ - getApiBaseUrl: vi.fn(() => 'https://api.test'), + apiFetch: vi.fn(), + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), })); -import { fetchAuthSession } from 'aws-amplify/auth'; +import { apiFetch, apiGet, apiPost, apiPut, apiPatch, apiDelete } from './client'; import { chatQuery, getChatHistory, @@ -27,37 +28,23 @@ import { deleteNegativeFeedback, } from './analytics'; -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(fetchAuthSession).mockResolvedValue({ - tokens: { idToken: { toString: () => 'test-token' } }, - } as any); }); describe('chatQuery', () => { it('sends a POST request and returns the result', async () => { const expected = { sql: 'SELECT 1', data: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPost).mockResolvedValueOnce(expected); const result = await chatQuery({ query: 'how many devices?' } as any); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify({ query: 'how many devices?' }), - }); + expect(apiPost).toHaveBeenCalledWith('/analytics/chat', { query: 'how many devices?' }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Internal error' }), - }); + vi.mocked(apiPost).mockRejectedValueOnce(new Error('Internal error')); await expect(chatQuery({ query: 'fail' } as any)).rejects.toThrow('Internal error'); }); @@ -66,19 +53,19 @@ describe('chatQuery', () => { describe('getChatHistory', () => { it('fetches chat history with default limit', async () => { const expected = { items: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); const result = await getChatHistory('user@test.com'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test/analytics/history?userEmail=user%40test.com&limit=50', - { headers: { Authorization: 'Bearer test-token' } } - ); + expect(apiGet).toHaveBeenCalledWith('/analytics/history', { + userEmail: 'user@test.com', + limit: 50, + }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error('Failed to fetch chat history: 403')); await expect(getChatHistory('user@test.com')).rejects.toThrow('Failed to fetch chat history: 403'); }); @@ -87,19 +74,19 @@ describe('getChatHistory', () => { describe('listSessions', () => { it('fetches sessions with custom limit', async () => { const expected = { sessions: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); const result = await listSessions('user@test.com', 10); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test/analytics/sessions?userEmail=user%40test.com&limit=10', - { headers: { Authorization: 'Bearer test-token' } } - ); + expect(apiGet).toHaveBeenCalledWith('/analytics/sessions', { + userEmail: 'user@test.com', + limit: 10, + }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error('Failed to fetch sessions: 500')); await expect(listSessions('user@test.com')).rejects.toThrow('Failed to fetch sessions: 500'); }); @@ -108,19 +95,18 @@ describe('listSessions', () => { describe('getSession', () => { it('fetches a specific session', async () => { const expected = { session: { id: 'sess-1' } }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); const result = await getSession('sess-1', 'user@test.com'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test/analytics/sessions/sess-1?userEmail=user%40test.com', - { headers: { Authorization: 'Bearer test-token' } } - ); + expect(apiGet).toHaveBeenCalledWith('/analytics/sessions/sess-1', { + userEmail: 'user@test.com', + }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error('Failed to fetch session: 404')); await expect(getSession('sess-1', 'user@test.com')).rejects.toThrow('Failed to fetch session: 404'); }); @@ -128,22 +114,15 @@ describe('getSession', () => { describe('deleteSession', () => { it('sends a DELETE request for the session', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }); + vi.mocked(apiDelete).mockResolvedValueOnce(undefined as any); await deleteSession('sess-1', 'user@test.com'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test/analytics/sessions/sess-1?userEmail=user%40test.com', - { method: 'DELETE', headers: { Authorization: 'Bearer test-token' } } - ); + expect(apiDelete).toHaveBeenCalledWith('/analytics/sessions/sess-1?userEmail=user%40test.com'); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Delete failed' }), - }); + vi.mocked(apiDelete).mockRejectedValueOnce(new Error('Delete failed')); await expect(deleteSession('sess-1', 'user@test.com')).rejects.toThrow('Delete failed'); }); @@ -152,30 +131,25 @@ describe('deleteSession', () => { describe('listRagDocuments', () => { it('fetches all RAG documents without docType', async () => { const expected = { documents: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); const result = await listRagDocuments(); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents', { - headers: { Authorization: 'Bearer test-token' }, - }); + expect(apiGet).toHaveBeenCalledWith('/analytics/rag-documents', undefined); expect(result).toEqual(expected); }); it('fetches RAG documents filtered by docType', async () => { const expected = { documents: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); await listRagDocuments('schema'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.test/analytics/rag-documents?doc_type=schema', - { headers: { Authorization: 'Bearer test-token' } } - ); + expect(apiGet).toHaveBeenCalledWith('/analytics/rag-documents', { doc_type: 'schema' }); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error('Failed to fetch RAG documents: 500')); await expect(listRagDocuments()).rejects.toThrow('Failed to fetch RAG documents: 500'); }); @@ -185,24 +159,16 @@ describe('createRagDocument', () => { it('sends a POST request to create a document', async () => { const doc = { doc_type: 'schema' as const, content: 'table info' }; const expected = { document: { id: 'doc-1', ...doc } }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPost).mockResolvedValueOnce(expected); const result = await createRagDocument(doc); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify(doc), - }); + expect(apiPost).toHaveBeenCalledWith('/analytics/rag-documents', doc); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: 'Invalid doc' }), - }); + vi.mocked(apiPost).mockRejectedValueOnce(new Error('Invalid doc')); await expect(createRagDocument({ doc_type: 'schema', content: '' })).rejects.toThrow('Invalid doc'); }); @@ -212,24 +178,16 @@ describe('updateRagDocument', () => { it('sends a PUT request to update a document', async () => { const doc = { content: 'updated content' }; const expected = { document: { id: 'doc-1', content: 'updated content' } }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPut).mockResolvedValueOnce(expected); const result = await updateRagDocument('doc-1', doc); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents/doc-1', { - method: 'PUT', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify(doc), - }); + expect(apiPut).toHaveBeenCalledWith('/analytics/rag-documents/doc-1', doc); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: () => Promise.resolve({ error: 'Not found' }), - }); + vi.mocked(apiPut).mockRejectedValueOnce(new Error('Not found')); await expect(updateRagDocument('doc-1', { content: 'x' })).rejects.toThrow('Not found'); }); @@ -237,22 +195,15 @@ describe('updateRagDocument', () => { describe('deleteRagDocument', () => { it('sends a DELETE request for the document', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }); + vi.mocked(apiDelete).mockResolvedValueOnce(undefined as any); await deleteRagDocument('doc-1'); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents/doc-1', { - method: 'DELETE', - headers: { Authorization: 'Bearer test-token' }, - }); + expect(apiDelete).toHaveBeenCalledWith('/analytics/rag-documents/doc-1'); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Server error' }), - }); + vi.mocked(apiDelete).mockRejectedValueOnce(new Error('Server error')); await expect(deleteRagDocument('doc-1')).rejects.toThrow('Server error'); }); @@ -261,24 +212,16 @@ describe('deleteRagDocument', () => { describe('toggleRagDocumentPin', () => { it('sends a PATCH request to pin a document', async () => { const expected = { id: 'doc-1', pinned: true }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPatch).mockResolvedValueOnce(expected); const result = await toggleRagDocumentPin('doc-1', true); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents/doc-1/pin', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify({ pinned: true }), - }); + expect(apiPatch).toHaveBeenCalledWith('/analytics/rag-documents/doc-1/pin', { pinned: true }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Pin failed' }), - }); + vi.mocked(apiPatch).mockRejectedValueOnce(new Error('Pin failed')); await expect(toggleRagDocumentPin('doc-1', true)).rejects.toThrow('Pin failed'); }); @@ -287,23 +230,16 @@ describe('toggleRagDocumentPin', () => { describe('reseedRagDocuments', () => { it('sends a POST request to reseed', async () => { const expected = { message: 'Reseeded 5 documents' }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPost).mockResolvedValueOnce(expected); const result = await reseedRagDocuments(); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rag-documents/reseed', { - method: 'POST', - headers: { Authorization: 'Bearer test-token' }, - }); + expect(apiPost).toHaveBeenCalledWith('/analytics/rag-documents/reseed'); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Reseed failed' }), - }); + vi.mocked(apiPost).mockRejectedValueOnce(new Error('Reseed failed')); await expect(reseedRagDocuments()).rejects.toThrow('Reseed failed'); }); @@ -312,24 +248,19 @@ describe('reseedRagDocuments', () => { describe('rerunQuery', () => { it('sends a POST request with sql and userEmail', async () => { const expected = { data: [{ count: 5 }] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPost).mockResolvedValueOnce(expected); const result = await rerunQuery('SELECT COUNT(*) FROM devices', 'user@test.com'); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/rerun', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify({ sql: 'SELECT COUNT(*) FROM devices', userEmail: 'user@test.com' }), + expect(apiPost).toHaveBeenCalledWith('/analytics/rerun', { + sql: 'SELECT COUNT(*) FROM devices', + userEmail: 'user@test.com', }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: 'Bad SQL' }), - }); + vi.mocked(apiPost).mockRejectedValueOnce(new Error('Bad SQL')); await expect(rerunQuery('BAD SQL', 'user@test.com')).rejects.toThrow('Bad SQL'); }); @@ -339,24 +270,16 @@ describe('submitFeedback', () => { it('sends a POST request with feedback', async () => { const req = { queryId: 'q-1', rating: 1, userEmail: 'user@test.com' } as any; const expected = { success: true, indexed: true }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiPost).mockResolvedValueOnce(expected); const result = await submitFeedback(req); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/feedback', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, - body: JSON.stringify(req), - }); + expect(apiPost).toHaveBeenCalledWith('/analytics/feedback', req); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Feedback failed' }), - }); + vi.mocked(apiPost).mockRejectedValueOnce(new Error('Feedback failed')); await expect(submitFeedback({} as any)).rejects.toThrow('Feedback failed'); }); @@ -365,18 +288,16 @@ describe('submitFeedback', () => { describe('listNegativeFeedback', () => { it('fetches feedback with default limit', async () => { const expected = { items: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(expected) }); + vi.mocked(apiGet).mockResolvedValueOnce(expected); const result = await listNegativeFeedback(); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/feedback?limit=100', { - headers: { Authorization: 'Bearer test-token' }, - }); + expect(apiGet).toHaveBeenCalledWith('/analytics/feedback', { limit: 100 }); expect(result).toEqual(expected); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + vi.mocked(apiGet).mockRejectedValueOnce(new Error('Failed to fetch feedback: 500')); await expect(listNegativeFeedback()).rejects.toThrow('Failed to fetch feedback: 500'); }); @@ -384,23 +305,18 @@ describe('listNegativeFeedback', () => { describe('deleteNegativeFeedback', () => { it('sends a DELETE request with userEmail and ratedAt', async () => { - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }); + vi.mocked(apiFetch).mockResolvedValueOnce(undefined as any); await deleteNegativeFeedback('user@test.com', 1700000000); - expect(mockFetch).toHaveBeenCalledWith('https://api.test/analytics/feedback', { + expect(apiFetch).toHaveBeenCalledWith('/analytics/feedback', { method: 'DELETE', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, body: JSON.stringify({ userEmail: 'user@test.com', ratedAt: 1700000000 }), }); }); it('throws on error response', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Delete failed' }), - }); + vi.mocked(apiFetch).mockRejectedValueOnce(new Error('Delete failed')); await expect(deleteNegativeFeedback('user@test.com', 1700000000)).rejects.toThrow('Delete failed'); }); diff --git a/songbird-dashboard/src/api/analytics.ts b/songbird-dashboard/src/api/analytics.ts index ac9ea6a..85555c2 100644 --- a/songbird-dashboard/src/api/analytics.ts +++ b/songbird-dashboard/src/api/analytics.ts @@ -1,8 +1,8 @@ -import { fetchAuthSession } from 'aws-amplify/auth'; -import { getApiBaseUrl } from './client'; +import { apiFetch, apiGet, apiPost, apiPut, apiPatch, apiDelete } from './client'; import type { ChatRequest, QueryResult, + QueryRow, ChatHistoryResponse, SessionListResponse, SessionResponse, @@ -13,131 +13,44 @@ import type { } from '@/types/analytics'; export async function chatQuery(request: ChatRequest): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `API error: ${response.status}`); - } - - return response.json(); + return apiPost('/analytics/chat', request); } export async function getChatHistory(userEmail: string, limit = 50): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/history?userEmail=${encodeURIComponent(userEmail)}&limit=${limit}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chat history: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/history', { userEmail, limit }); } /** * List all chat sessions for a user */ export async function listSessions(userEmail: string, limit = 20): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions?userEmail=${encodeURIComponent(userEmail)}&limit=${limit}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch sessions: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/sessions', { userEmail, limit }); } /** * Get a specific chat session with all messages */ export async function getSession(sessionId: string, userEmail: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch session: ${response.status}`); - } - - return response.json(); + return apiGet(`/analytics/sessions/${encodeURIComponent(sessionId)}`, { userEmail }); } /** * Delete a chat session */ export async function deleteSession(sessionId: string, userEmail: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}`, - { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}`, - }, - } + return apiDelete( + `/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}` ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete session: ${response.status}`); - } } /** - * Re-execute a stored SQL query to get fresh visualization data + * List RAG documents, optionally filtered by type */ export async function listRagDocuments(docType?: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - const params = docType ? `?doc_type=${encodeURIComponent(docType)}` : ''; - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents${params}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch RAG documents: ${response.status}`); - } - return response.json(); + return apiGet( + '/analytics/rag-documents', + docType ? { doc_type: docType } : undefined + ); } export async function createRagDocument(doc: { @@ -146,159 +59,49 @@ export async function createRagDocument(doc: { content: string; metadata?: Record; }): Promise<{ document: RagDocument }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify(doc), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to create RAG document: ${response.status}`); - } - return response.json(); + return apiPost<{ document: RagDocument }>('/analytics/rag-documents', doc); } export async function updateRagDocument( id: string, doc: { title?: string; content: string } ): Promise<{ document: RagDocument }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify(doc), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to update RAG document: ${response.status}`); - } - return response.json(); + return apiPut<{ document: RagDocument }>( + `/analytics/rag-documents/${encodeURIComponent(id)}`, + doc + ); } export async function deleteRagDocument(id: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete RAG document: ${response.status}`); - } + return apiDelete(`/analytics/rag-documents/${encodeURIComponent(id)}`); } export async function toggleRagDocumentPin(id: string, pinned: boolean): Promise<{ id: string; pinned: boolean }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}/pin`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ pinned }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to toggle pin: ${response.status}`); - } - return response.json(); + return apiPatch<{ id: string; pinned: boolean }>( + `/analytics/rag-documents/${encodeURIComponent(id)}/pin`, + { pinned } + ); } export async function reseedRagDocuments(): Promise<{ message: string }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/reseed`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to reseed: ${response.status}`); - } - return response.json(); + return apiPost<{ message: string }>('/analytics/rag-documents/reseed'); } -export async function rerunQuery(sql: string, userEmail: string): Promise<{ data: any[] }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rerun`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ sql, userEmail }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to rerun query: ${response.status}`); - } - - return response.json(); +export async function rerunQuery(sql: string, userEmail: string): Promise<{ data: QueryRow[] }> { + return apiPost<{ data: QueryRow[] }>('/analytics/rerun', { sql, userEmail }); } export async function deleteNegativeFeedback(userEmail: string, ratedAt: number): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback`, { + return apiFetch('/analytics/feedback', { method: 'DELETE', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ userEmail, ratedAt }), }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete feedback: ${response.status}`); - } } export async function listNegativeFeedback(limit = 100): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback?limit=${limit}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch feedback: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/feedback', { limit }); } export async function submitFeedback(req: FeedbackRequest): Promise<{ success: boolean; indexed: boolean }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(req), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to submit feedback: ${response.status}`); - } - - return response.json(); + return apiPost<{ success: boolean; indexed: boolean }>('/analytics/feedback', req); } diff --git a/songbird-dashboard/src/components/analytics/QueryVisualization.tsx b/songbird-dashboard/src/components/analytics/QueryVisualization.tsx index 1e6c52b..fcfa18d 100644 --- a/songbird-dashboard/src/components/analytics/QueryVisualization.tsx +++ b/songbird-dashboard/src/components/analytics/QueryVisualization.tsx @@ -16,7 +16,7 @@ import { import Map, { Marker, NavigationControl } from 'react-map-gl'; import { MapPin } from 'lucide-react'; import { Card } from '@/components/ui/card'; -import type { QueryResult } from '@/types/analytics'; +import type { QueryResult, QueryRow } from '@/types/analytics'; import 'mapbox-gl/dist/mapbox-gl.css'; interface QueryVisualizationProps { @@ -58,7 +58,7 @@ export function QueryVisualization({ result, mapboxToken }: QueryVisualizationPr } } -function LineChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function LineChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { // Get numeric keys for line series const keys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; @@ -111,7 +111,7 @@ function LineChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function BarChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function BarChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { const keys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; return typeof val === 'number'; @@ -160,7 +160,7 @@ function BarChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function ScatterChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function ScatterChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { const numericKeys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; return typeof val === 'number'; @@ -192,7 +192,7 @@ function ScatterChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { +function MapViz({ data, mapboxToken }: { data: QueryRow[]; mapboxToken: string }) { // Extract and normalize location data const locations = useMemo(() => { return data @@ -202,21 +202,24 @@ function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { const lonValue = row.lon ?? row.longitude ?? row.last_location_lon; // Parse values - they might be strings or numbers - const lat = typeof latValue === 'string' ? parseFloat(latValue) : latValue; - const lon = typeof lonValue === 'string' ? parseFloat(lonValue) : lonValue; + const lat = typeof latValue === 'string' ? parseFloat(latValue) : typeof latValue === 'number' ? latValue : null; + const lon = typeof lonValue === 'string' ? parseFloat(lonValue) : typeof lonValue === 'number' ? lonValue : null; // Validate coordinates if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) { return null; } + const timeVal = row.time; + const time = typeof timeVal === 'string' || typeof timeVal === 'number' ? timeVal : null; + return { id: index, lat, lon, - name: row.name || row.serial_number || `Location ${index + 1}`, - time: row.time, - source: row.source, + name: String(row.name || row.serial_number || `Location ${index + 1}`), + time, + source: row.source != null ? String(row.source) : null, }; }) .filter((loc): loc is NonNullable => loc !== null); @@ -343,7 +346,7 @@ function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { ); } -function GaugeViz({ data }: { data: any[] }) { +function GaugeViz({ data }: { data: QueryRow[] }) { const row = data[0] || {}; const value = Object.values(row).find(v => typeof v === 'number') as number; const label = Object.keys(row).find(k => typeof row[k] === 'number') || 'Value'; @@ -357,7 +360,7 @@ function GaugeViz({ data }: { data: any[] }) { ); } -function TableViz({ data }: { data: any[] }) { +function TableViz({ data }: { data: QueryRow[] }) { if (data.length === 0) return null; const columns = Object.keys(data[0]); @@ -401,11 +404,10 @@ function TableViz({ data }: { data: any[] }) { ); } -function formatCellValue(value: any): string { +function formatCellValue(value: string | number | boolean | null): string { if (value === null || value === undefined) return '--'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'number') return value.toFixed(2); - if (value instanceof Date) return value.toLocaleString(); if (typeof value === 'string' && value.length > 50) return value.substring(0, 50) + '...'; return String(value); } diff --git a/songbird-dashboard/src/components/settings/DisplayPreferences.tsx b/songbird-dashboard/src/components/settings/DisplayPreferences.tsx index b3138f4..69c9bc6 100644 --- a/songbird-dashboard/src/components/settings/DisplayPreferences.tsx +++ b/songbird-dashboard/src/components/settings/DisplayPreferences.tsx @@ -48,8 +48,9 @@ export function DisplayPreferences() { }; const handleSave = () => { - updatePreferences.mutate(localPrefs); - setHasChanges(false); + updatePreferences.mutate(localPrefs, { + onSuccess: () => setHasChanges(false), + }); }; if (isLoading) { @@ -213,6 +214,11 @@ export function DisplayPreferences() { {updatePreferences.isSuccess && !hasChanges && ( Preferences saved! )} + {updatePreferences.isError && ( + + Failed to save preferences. Please try again. + + )} diff --git a/songbird-dashboard/src/components/settings/FleetDefaults.tsx b/songbird-dashboard/src/components/settings/FleetDefaults.tsx index 6dee849..680cbdb 100644 --- a/songbird-dashboard/src/components/settings/FleetDefaults.tsx +++ b/songbird-dashboard/src/components/settings/FleetDefaults.tsx @@ -34,7 +34,6 @@ export function FleetDefaults() { const useFahrenheit = preferences.temp_unit === 'fahrenheit'; const tempUnit = useFahrenheit ? '°F' : '°C'; - // Convert display temperature based on preference // Convert display temperature based on preference (rounded for slider display) const displayTemp = (celsius: number) => useFahrenheit ? Math.round(celsiusToFahrenheit(celsius)) : celsius; @@ -72,11 +71,10 @@ export function FleetDefaults() { const handleSave = () => { if (!selectedFleet) return; - updateDefaults.mutate({ - fleetUid: selectedFleet, - config: localConfig, - }); - setHasChanges(false); + updateDefaults.mutate( + { fleetUid: selectedFleet, config: localConfig }, + { onSuccess: () => setHasChanges(false) } + ); }; if (fleetsLoading) { @@ -442,6 +440,11 @@ export function FleetDefaults() { Defaults saved and synced to Notehub! )} + {updateDefaults.isError && ( + + Failed to save fleet defaults. Please try again. + + )} {fleetConfig?.updated_at && (

Last updated: {new Date(fleetConfig.updated_at).toLocaleString()} diff --git a/songbird-dashboard/src/hooks/useAuth.test.tsx b/songbird-dashboard/src/hooks/useAuth.test.tsx index c4ffcf1..a74e4c3 100644 --- a/songbird-dashboard/src/hooks/useAuth.test.tsx +++ b/songbird-dashboard/src/hooks/useAuth.test.tsx @@ -1,4 +1,6 @@ import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; import { useIsAdmin, useUserGroups, @@ -19,6 +21,15 @@ vi.mock('posthog-js', () => ({ import { fetchAuthSession } from 'aws-amplify/auth'; import posthog from 'posthog-js'; +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0, gcTime: 0 } }, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + function mockSession(groups: string[], email = 'user@test.com', sub = 'user-123') { vi.mocked(fetchAuthSession).mockResolvedValue({ tokens: { @@ -42,7 +53,7 @@ describe('useIsAdmin', () => { it('returns true when groups include Admin', async () => { mockSession(['Admin', 'Sales']); - const { result } = renderHook(() => useIsAdmin()); + const { result } = renderHook(() => useIsAdmin(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.isAdmin).toBe(true); @@ -51,7 +62,7 @@ describe('useIsAdmin', () => { it('returns false when groups do not include Admin', async () => { mockSession(['Sales']); - const { result } = renderHook(() => useIsAdmin()); + const { result } = renderHook(() => useIsAdmin(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.isAdmin).toBe(false); @@ -60,7 +71,7 @@ describe('useIsAdmin', () => { it('returns false when session fails', async () => { vi.mocked(fetchAuthSession).mockRejectedValue(new Error('Not authenticated')); - const { result } = renderHook(() => useIsAdmin()); + const { result } = renderHook(() => useIsAdmin(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.isAdmin).toBe(false); @@ -75,7 +86,7 @@ describe('useUserGroups', () => { it('returns groups from session', async () => { mockSession(['Admin', 'Sales']); - const { result } = renderHook(() => useUserGroups()); + const { result } = renderHook(() => useUserGroups(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.groups).toEqual(['Admin', 'Sales']); @@ -84,7 +95,7 @@ describe('useUserGroups', () => { it('returns empty array when session fails', async () => { vi.mocked(fetchAuthSession).mockRejectedValue(new Error('fail')); - const { result } = renderHook(() => useUserGroups()); + const { result } = renderHook(() => useUserGroups(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.groups).toEqual([]); @@ -99,7 +110,7 @@ describe('useCanSendCommands', () => { it('returns false for Viewer-only user', async () => { mockSession(['Viewer']); - const { result } = renderHook(() => useCanSendCommands()); + const { result } = renderHook(() => useCanSendCommands(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canSend).toBe(false); @@ -108,7 +119,7 @@ describe('useCanSendCommands', () => { it('returns true for Admin', async () => { mockSession(['Admin']); - const { result } = renderHook(() => useCanSendCommands()); + const { result } = renderHook(() => useCanSendCommands(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canSend).toBe(true); @@ -117,7 +128,7 @@ describe('useCanSendCommands', () => { it('returns true for Sales', async () => { mockSession(['Sales']); - const { result } = renderHook(() => useCanSendCommands()); + const { result } = renderHook(() => useCanSendCommands(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canSend).toBe(true); @@ -126,7 +137,7 @@ describe('useCanSendCommands', () => { it('returns false when no groups', async () => { mockSession([]); - const { result } = renderHook(() => useCanSendCommands()); + const { result } = renderHook(() => useCanSendCommands(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canSend).toBe(false); @@ -141,7 +152,7 @@ describe('useCurrentUserEmail', () => { it('returns email from session', async () => { mockSession(['Admin'], 'admin@test.com'); - const { result } = renderHook(() => useCurrentUserEmail()); + const { result } = renderHook(() => useCurrentUserEmail(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.email).toBe('admin@test.com'); @@ -150,7 +161,7 @@ describe('useCurrentUserEmail', () => { it('returns null when session fails', async () => { vi.mocked(fetchAuthSession).mockRejectedValue(new Error('fail')); - const { result } = renderHook(() => useCurrentUserEmail()); + const { result } = renderHook(() => useCurrentUserEmail(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.email).toBeNull(); @@ -165,7 +176,7 @@ describe('useCanUnlockDevice', () => { it('returns true for admin regardless of assignedTo', async () => { mockSession(['Admin'], 'admin@test.com'); - const { result } = renderHook(() => useCanUnlockDevice('other@test.com')); + const { result } = renderHook(() => useCanUnlockDevice('other@test.com'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canUnlock).toBe(true); @@ -174,7 +185,7 @@ describe('useCanUnlockDevice', () => { it('returns true when user is the device owner', async () => { mockSession(['Sales'], 'owner@test.com'); - const { result } = renderHook(() => useCanUnlockDevice('owner@test.com')); + const { result } = renderHook(() => useCanUnlockDevice('owner@test.com'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canUnlock).toBe(true); @@ -183,7 +194,7 @@ describe('useCanUnlockDevice', () => { it('returns false when user is not admin and not the owner', async () => { mockSession(['Sales'], 'other@test.com'); - const { result } = renderHook(() => useCanUnlockDevice('owner@test.com')); + const { result } = renderHook(() => useCanUnlockDevice('owner@test.com'), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.canUnlock).toBe(false); @@ -198,7 +209,7 @@ describe('usePostHogIdentify', () => { it('calls posthog.identify with user info', async () => { mockSession(['Admin'], 'admin@test.com', 'sub-123'); - renderHook(() => usePostHogIdentify()); + renderHook(() => usePostHogIdentify(), { wrapper: createWrapper() }); await waitFor(() => { expect(posthog.identify).toHaveBeenCalledWith('sub-123', { diff --git a/songbird-dashboard/src/hooks/useAuth.ts b/songbird-dashboard/src/hooks/useAuth.ts index 70e4987..cb2eaa1 100644 --- a/songbird-dashboard/src/hooks/useAuth.ts +++ b/songbird-dashboard/src/hooks/useAuth.ts @@ -5,8 +5,9 @@ * Integrates with PostHog for user identification. */ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { fetchAuthSession } from 'aws-amplify/auth'; +import { useQuery } from '@tanstack/react-query'; import posthog from 'posthog-js'; import type { UserGroup } from '@/types'; @@ -30,47 +31,32 @@ async function getUserGroups(): Promise { } } +/** + * Shared TanStack Query hook for user groups — a single Cognito round-trip + * is shared across all components that call any of the auth hooks simultaneously. + */ +function useUserGroupsQuery() { + return useQuery({ + queryKey: ['authSession', 'groups'], + queryFn: getUserGroups, + staleTime: 5 * 60_000, + retry: 1, + }); +} + /** * Hook to check if the current user is an admin */ export function useIsAdmin() { - const [isAdmin, setIsAdmin] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(groups => { - setIsAdmin(groups.includes('Admin')); - setIsLoading(false); - }) - .catch(() => { - setIsAdmin(false); - setIsLoading(false); - }); - }, []); - - return { isAdmin, isLoading }; + const { data: groups = [], isLoading } = useUserGroupsQuery(); + return { isAdmin: groups.includes('Admin'), isLoading }; } /** * Hook to get the current user's groups */ export function useUserGroups() { - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(g => { - setGroups(g); - setIsLoading(false); - }) - .catch(() => { - setGroups([]); - setIsLoading(false); - }); - }, []); - + const { data: groups = [], isLoading } = useUserGroupsQuery(); return { groups, isLoading }; } @@ -79,49 +65,28 @@ export function useUserGroups() { * Returns true for all roles except Viewer */ export function useCanSendCommands() { - const [canSend, setCanSend] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(groups => { - // Viewers can only view, not send commands - // If user has no groups or only Viewer group, they cannot send - const isViewerOnly = groups.length === 0 || - (groups.length === 1 && groups.includes('Viewer')); - setCanSend(!isViewerOnly); - setIsLoading(false); - }) - .catch(() => { - setCanSend(false); - setIsLoading(false); - }); - }, []); - - return { canSend, isLoading }; + const { data: groups = [], isLoading } = useUserGroupsQuery(); + // Viewers can only view, not send commands + // If user has no groups or only Viewer group, they cannot send + const isViewerOnly = groups.length === 0 || + (groups.length === 1 && groups.includes('Viewer')); + return { canSend: !isViewerOnly, isLoading }; } /** * Hook to get the current user's email */ export function useCurrentUserEmail() { - const [email, setEmail] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchAuthSession() - .then(session => { - const userEmail = session.tokens?.idToken?.payload['email'] as string | undefined; - setEmail(userEmail || null); - setIsLoading(false); - }) - .catch(() => { - setEmail(null); - setIsLoading(false); - }); - }, []); - - return { email, isLoading }; + const { data, isLoading } = useQuery({ + queryKey: ['authSession', 'email'], + queryFn: async () => { + const session = await fetchAuthSession(); + return (session.tokens?.idToken?.payload['email'] as string | undefined) ?? null; + }, + staleTime: 5 * 60_000, + retry: 1, + }); + return { email: data ?? null, isLoading }; } /** diff --git a/songbird-dashboard/src/pages/Analytics.tsx b/songbird-dashboard/src/pages/Analytics.tsx index 392543d..e89968f 100644 --- a/songbird-dashboard/src/pages/Analytics.tsx +++ b/songbird-dashboard/src/pages/Analytics.tsx @@ -167,10 +167,11 @@ export function Analytics({ mapboxToken }: AnalyticsProps) { }; setMessages(prev => [...prev, assistantMessage]); - } catch (error: any) { + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to process query'; const errorMessage = { type: 'assistant' as const, - content: `Error: ${error.message || 'Failed to process query'}`, + content: `Error: ${message}`, timestamp: Date.now(), }; setMessages(prev => [...prev, errorMessage]); @@ -311,8 +312,8 @@ export function Analytics({ mapboxToken }: AnalyticsProps) {

) : ( - messages.map((message, index) => ( - + messages.map((message) => ( + )) )} {chatMutation.isPending && ( diff --git a/songbird-dashboard/src/types/analytics.ts b/songbird-dashboard/src/types/analytics.ts index 1a64234..f443006 100644 --- a/songbird-dashboard/src/types/analytics.ts +++ b/songbird-dashboard/src/types/analytics.ts @@ -1,8 +1,10 @@ +export type QueryRow = Record; + export interface QueryResult { sql: string; explanation: string; visualizationType: 'line_chart' | 'bar_chart' | 'table' | 'map' | 'scatter' | 'gauge'; - data: any[]; + data: QueryRow[]; insights: string; savedTimestamp?: number; // exact DynamoDB sort key for feedback linking }