diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 4290c05..45faebd 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -4741,7 +4741,7 @@ body { .notif-search-form__row { display: grid; - grid-template-columns: 2fr repeat(5, 1fr); + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; align-items: end; } @@ -4762,6 +4762,10 @@ body { font-weight: 500; } +.notif-search-form__input--date { + min-width: 0; +} + .notif-search-form__input { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx index bfc57c3..008d1ae 100644 --- a/dashboard/src/pages/NotificationSearchPage.test.tsx +++ b/dashboard/src/pages/NotificationSearchPage.test.tsx @@ -1,3 +1,35 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { NotificationSearchPage } from './NotificationSearchPage'; +import * as eventsApi from '../services/eventsApi'; + +jest.mock('../services/eventsApi', () => { + const actual = jest.requireActual('../services/eventsApi') as typeof import('../services/eventsApi'); + return { + ...actual, + searchNotifications: jest.fn(), + }; +}); + +const searchNotifications = eventsApi.searchNotifications as jest.MockedFunction< + typeof eventsApi.searchNotifications +>; + +function emptyResponse(): eventsApi.NotificationSearchResponse { + return { + results: [], + total: 0, + limit: 20, + offset: 0, + itemCount: 0, + totalPages: 0, + }; +} + +describe('NotificationSearchPage filters', () => { + beforeEach(() => { + jest.useFakeTimers(); + searchNotifications.mockReset(); + searchNotifications.mockResolvedValue(emptyResponse()); import '@testing-library/jest-dom'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { NotificationSearchPage } from './NotificationSearchPage'; @@ -42,6 +74,150 @@ describe('NotificationSearchPage loading skeletons', () => { jest.useRealTimers(); }); + it('renders type, delivery status, and date filter controls', () => { + render(); + + expect(screen.getByLabelText(/filter by notification type/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/filter by delivery status/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/filter from date/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/filter to date/i)).toBeInTheDocument(); + + const typeSelect = screen.getByLabelText(/filter by notification type/i); + expect(typeSelect).toContainHTML('Discord'); + expect(typeSelect).toContainHTML('Email'); + expect(typeSelect).toContainHTML('Webhook'); + expect(typeSelect).toContainHTML('SMS'); + }); + + it('calls searchNotifications with type, status, and date params', async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText(/filter by notification type/i), { + target: { value: 'discord' }, + }); + fireEvent.change(screen.getByLabelText(/filter by delivery status/i), { + target: { value: 'FAILED' }, + }); + fireEvent.change(screen.getByLabelText(/filter from date/i), { + target: { value: '2026-01-01' }, + }); + fireEvent.change(screen.getByLabelText(/filter to date/i), { + target: { value: '2026-01-31' }, + }); + }); + + await waitFor(() => { + expect(searchNotifications).toHaveBeenCalled(); + }); + + const lastCall = searchNotifications.mock.calls[searchNotifications.mock.calls.length - 1]; + expect(lastCall?.[1]).toMatchObject({ + type: 'discord', + status: 'FAILED', + startDate: '2026-01-01', + endDate: '2026-01-31', + }); + }); + + it('updates results when filters change', async () => { + searchNotifications.mockResolvedValue({ + results: [ + { + id: 1, + source: 'scheduled', + eventId: 'evt-1', + txHash: null, + contractAddress: null, + notificationType: 'email', + targetRecipient: 'alice', + status: 'COMPLETED', + createdAt: '2026-03-01T00:00:00.000Z', + payload: null, + }, + ], + total: 1, + limit: 20, + offset: 0, + itemCount: 1, + totalPages: 1, + }); + + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText(/filter by notification type/i), { + target: { value: 'email' }, + }); + }); + + expect(await screen.findByText('email')).toBeInTheDocument(); + expect(screen.getByText('COMPLETED')).toBeInTheDocument(); + expect(screen.getByText(/1 result/i)).toBeInTheDocument(); + }); + + it('clears type, status, and date filters', async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByLabelText(/filter by notification type/i), { + target: { value: 'sms' }, + }); + fireEvent.change(screen.getByLabelText(/filter by delivery status/i), { + target: { value: 'PENDING' }, + }); + fireEvent.change(screen.getByLabelText(/filter from date/i), { + target: { value: '2026-02-01' }, + }); + }); + + expect(screen.getByRole('button', { name: /clear all filters/i })).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /clear all filters/i })); + }); + + expect(screen.getByLabelText(/filter by notification type/i)).toHaveValue(''); + expect(screen.getByLabelText(/filter by delivery status/i)).toHaveValue(''); + expect(screen.getByLabelText(/filter from date/i)).toHaveValue(''); + expect(screen.queryByRole('button', { name: /clear all filters/i })).not.toBeInTheDocument(); + }); +}); + +describe('searchNotifications query params', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => emptyResponse(), + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('appends type, status, startDate, and endDate to the URL', async () => { + // Use the real implementation (not the page mock) + const { searchNotifications: realSearch } = jest.requireActual( + '../services/eventsApi' + ) as typeof import('../services/eventsApi'); + + await realSearch('http://localhost:8787', { + type: 'webhook', + status: 'COMPLETED', + startDate: '2026-01-01', + endDate: '2026-01-31', + }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('type=webhook') + ); + const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; + expect(calledUrl).toContain('status=COMPLETED'); + expect(calledUrl).toContain('startDate=2026-01-01'); + expect(calledUrl).toContain('endDate=2026-01-31'); it('shows result-card skeletons while searching and hides Searching text', async () => { mockedSearch.mockReturnValue(new Promise(() => {})); diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx index 9aa24f1..bf0655c 100644 --- a/dashboard/src/pages/NotificationSearchPage.tsx +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { NotificationSearchSkeleton } from '../components/NotificationSearchSkeleton'; import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { useDebounce } from '../hooks/useDebounce'; +import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { searchNotifications, type NotificationSearchResult, @@ -9,6 +10,27 @@ import { } from '../services/eventsApi'; const PAGE_SIZE = 20; +const API_BASE = getEventsApiBaseUrl().replace(/\/api\/events\/?$/, ''); + +/** Delivery / processing status values used by scheduled + processed notifications. */ +export const NOTIFICATION_DELIVERY_STATUS_OPTIONS = [ + { value: '', label: 'All statuses' }, + { value: 'PENDING', label: 'Pending' }, + { value: 'PROCESSING', label: 'Processing' }, + { value: 'COMPLETED', label: 'Completed' }, + { value: 'FAILED', label: 'Failed' }, + { value: 'CANCELLED', label: 'Cancelled' }, + { value: 'PROCESSED', label: 'Processed' }, +]; + +/** Known notification channel types (listener NotificationType). */ +export const NOTIFICATION_TYPE_OPTIONS = [ + { value: '', label: 'All types' }, + { value: 'discord', label: 'Discord' }, + { value: 'email', label: 'Email' }, + { value: 'webhook', label: 'Webhook' }, + { value: 'sms', label: 'SMS' }, +]; const API_BASE = getEventsApiBaseUrl(); const STATUS_OPTIONS = ['', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'PROCESSED']; @@ -20,6 +42,8 @@ export function NotificationSearchPage() { const [eventId, setEventId] = useState(''); const [status, setStatus] = useState(''); const [type, setType] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); const [page, setPage] = useState(1); const [response, setResponse] = useState(null); @@ -33,7 +57,14 @@ export function NotificationSearchPage() { // Track whether any search param is active const hasParams = - debouncedQuery || debouncedSender || debouncedTxHash || debouncedEventId || status || type; + debouncedQuery || + debouncedSender || + debouncedTxHash || + debouncedEventId || + status || + type || + dateFrom || + dateTo; const abortRef = useRef(null); @@ -58,6 +89,8 @@ export function NotificationSearchPage() { eventId: debouncedEventId || undefined, status: status || undefined, type: type || undefined, + startDate: dateFrom || undefined, + endDate: dateTo || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }); @@ -68,16 +101,27 @@ export function NotificationSearchPage() { } finally { setLoading(false); } - }, [debouncedQuery, debouncedSender, debouncedTxHash, debouncedEventId, status, type, page, hasParams]); + }, [ + debouncedQuery, + debouncedSender, + debouncedTxHash, + debouncedEventId, + status, + type, + dateFrom, + dateTo, + page, + hasParams, + ]); // Re-run search whenever debounced params change; reset page when filters change - const filtersKey = `${debouncedQuery}|${debouncedSender}|${debouncedTxHash}|${debouncedEventId}|${status}|${type}`; + const filtersKey = `${debouncedQuery}|${debouncedSender}|${debouncedTxHash}|${debouncedEventId}|${status}|${type}|${dateFrom}|${dateTo}`; const prevFiltersRef = useRef(filtersKey); useEffect(() => { if (filtersKey !== prevFiltersRef.current) { setPage(1); - prevFiltersRef.current = filtersKey; } + prevFiltersRef.current = filtersKey; }, [filtersKey]); useEffect(() => { @@ -91,6 +135,8 @@ export function NotificationSearchPage() { setEventId(''); setStatus(''); setType(''); + setDateFrom(''); + setDateTo(''); setPage(1); setResponse(null); setError(null); @@ -104,7 +150,7 @@ export function NotificationSearchPage() {

Notifications

Notification Search

- Search scheduled and processed notifications by sender, transaction hash, event ID, type, or free-text. + Filter scheduled and processed notifications by type, delivery status, date range, sender, or free-text.

@@ -161,28 +207,58 @@ export function NotificationSearchPage() {
- +
- - Notification type + +
+ +
+ + setDateFrom(e.target.value)} + aria-label="Filter from date" + /> +
+ +
+ + setDateTo(e.target.value)} + aria-label="Filter to date" />
@@ -207,7 +283,7 @@ export function NotificationSearchPage() { {!loading && !error && !hasParams && (

Start searching

-

Enter a query above to find notifications by sender, transaction hash, event ID, or type.

+

Choose a type, delivery status, date range, or enter a query to find notifications.

)} diff --git a/dashboard/src/services/eventsApi.ts b/dashboard/src/services/eventsApi.ts index a1e1cd5..eae2ce9 100644 --- a/dashboard/src/services/eventsApi.ts +++ b/dashboard/src/services/eventsApi.ts @@ -59,6 +59,10 @@ export interface NotificationSearchParams { eventId?: string; status?: string; type?: string; + /** Inclusive lower bound (YYYY-MM-DD or ISO datetime) */ + startDate?: string; + /** Inclusive upper bound (YYYY-MM-DD or ISO datetime) */ + endDate?: string; limit?: number; offset?: number; } @@ -93,6 +97,8 @@ export async function searchNotifications( if (params.eventId) url.searchParams.set('eventId', params.eventId); if (params.status) url.searchParams.set('status', params.status); if (params.type) url.searchParams.set('type', params.type); + if (params.startDate) url.searchParams.set('startDate', params.startDate); + if (params.endDate) url.searchParams.set('endDate', params.endDate); if (params.limit !== undefined) url.searchParams.set('limit', String(params.limit)); if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 2ecd990..df3cc00 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -919,12 +919,38 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const eventId = url.searchParams.get('eventId') ?? undefined; const status = url.searchParams.get('status') ?? undefined; const type = url.searchParams.get('type') ?? undefined; + const startDate = url.searchParams.get('startDate') ?? undefined; + const endDate = url.searchParams.get('endDate') ?? undefined; const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit')!, 10) : undefined; const offset = url.searchParams.get('offset') ? parseInt(url.searchParams.get('offset')!, 10) : undefined; - logger.info('Handling GET /api/notifications/search', { requestId, correlationId, q, sender, txHash, eventId, status, type, limit, offset }); + logger.info('Handling GET /api/notifications/search', { + requestId, + correlationId, + q, + sender, + txHash, + eventId, + status, + type, + startDate, + endDate, + limit, + offset, + }); - notificationSearchService.search({ q, sender, txHash, eventId, status, type, limit, offset }) + notificationSearchService.search({ + q, + sender, + txHash, + eventId, + status, + type, + startDate, + endDate, + limit, + offset, + }) .then((result) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); diff --git a/listener/src/services/notification-search-service.test.ts b/listener/src/services/notification-search-service.test.ts new file mode 100644 index 0000000..ff4ad71 --- /dev/null +++ b/listener/src/services/notification-search-service.test.ts @@ -0,0 +1,157 @@ +import { + NotificationSearchService, + normalizeSearchDateBound, + type NotificationSearchParams, +} from './notification-search-service'; + +jest.mock('../database/database', () => { + const mockDb = { + get: jest.fn(), + all: jest.fn(), + }; + return { + getDatabase: () => mockDb, + __mockDb: mockDb, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { __mockDb: mockDb } = require('../database/database') as { + __mockDb: { get: jest.Mock; all: jest.Mock }; +}; + +describe('normalizeSearchDateBound', () => { + it('expands YYYY-MM-DD to full UTC day bounds', () => { + expect(normalizeSearchDateBound('2026-01-15', 'start')).toBe('2026-01-15T00:00:00.000Z'); + expect(normalizeSearchDateBound('2026-01-15', 'end')).toBe('2026-01-15T23:59:59.999Z'); + }); + + it('leaves ISO datetimes unchanged', () => { + expect(normalizeSearchDateBound('2026-01-15T12:00:00.000Z', 'start')).toBe( + '2026-01-15T12:00:00.000Z' + ); + }); +}); + +describe('NotificationSearchService filters', () => { + let service: NotificationSearchService; + + beforeEach(() => { + mockDb.get.mockReset(); + mockDb.all.mockReset(); + mockDb.get.mockResolvedValue({ count: 0 }); + mockDb.all.mockResolvedValue([]); + service = new NotificationSearchService(); + }); + + function scheduledCalls() { + return mockDb.get.mock.calls.filter( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('FROM scheduled_notifications') + ); + } + + function processedCalls() { + return mockDb.get.mock.calls.filter( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('FROM processed_events') + ); + } + + it('filters by notification type with exact match', async () => { + await service.search({ type: 'email' }); + + const [sql, params] = scheduledCalls()[0]; + expect(sql).toContain('LOWER(notification_type) = ?'); + expect(params).toContain('email'); + + const [processedSql, processedParams] = processedCalls()[0]; + expect(processedSql).toContain('LOWER(event_type) = ?'); + expect(processedParams).toContain('email'); + }); + + it('filters by delivery status', async () => { + await service.search({ status: 'FAILED' }); + + const [sql, params] = scheduledCalls()[0]; + expect(sql).toContain('status = ?'); + expect(params).toContain('FAILED'); + }); + + it('filters by date range using normalized bounds', async () => { + const params: NotificationSearchParams = { + startDate: '2026-01-10', + endDate: '2026-01-20', + }; + await service.search(params); + + const [sql, queryParams] = scheduledCalls()[0]; + expect(sql).toContain('created_at >= ?'); + expect(sql).toContain('created_at <= ?'); + expect(queryParams).toContain('2026-01-10T00:00:00.000Z'); + expect(queryParams).toContain('2026-01-20T23:59:59.999Z'); + + const [processedSql, processedParams] = processedCalls()[0]; + expect(processedSql).toContain('processed_at >= ?'); + expect(processedSql).toContain('processed_at <= ?'); + expect(processedParams).toContain('2026-01-10T00:00:00.000Z'); + expect(processedParams).toContain('2026-01-20T23:59:59.999Z'); + }); + + it('combines type, status, and date filters', async () => { + await service.search({ + type: 'webhook', + status: 'COMPLETED', + startDate: '2026-04-01', + endDate: '2026-04-30', + }); + + const [sql, params] = scheduledCalls()[0]; + expect(sql).toContain('LOWER(notification_type) = ?'); + expect(sql).toContain('status = ?'); + expect(sql).toContain('created_at >= ?'); + expect(sql).toContain('created_at <= ?'); + expect(params).toEqual( + expect.arrayContaining([ + 'webhook', + 'COMPLETED', + '2026-04-01T00:00:00.000Z', + '2026-04-30T23:59:59.999Z', + ]) + ); + }); + + it('returns merged scheduled results for matching filters', async () => { + mockDb.get.mockImplementation(async (sql: string) => { + if (sql.includes('scheduled_notifications')) return { count: 1 }; + return { count: 0 }; + }); + mockDb.all.mockImplementation(async (sql: string) => { + if (sql.includes('FROM scheduled_notifications')) { + return [ + { + id: 7, + event_id: 'match', + contract_address: null, + notification_type: 'webhook', + target_recipient: 'hook', + status: 'COMPLETED', + created_at: '2026-04-01T08:00:00.000Z', + payload: '{}', + }, + ]; + } + return []; + }); + + const result = await service.search({ + type: 'webhook', + status: 'COMPLETED', + startDate: '2026-04-01', + endDate: '2026-04-30', + }); + + expect(result.total).toBe(1); + expect(result.results[0].eventId).toBe('match'); + expect(result.results[0].notificationType).toBe('webhook'); + expect(result.results[0].status).toBe('COMPLETED'); + }); +}); diff --git a/listener/src/services/notification-search-service.ts b/listener/src/services/notification-search-service.ts index 55ec8ae..8ad90ed 100644 --- a/listener/src/services/notification-search-service.ts +++ b/listener/src/services/notification-search-service.ts @@ -7,12 +7,20 @@ export interface NotificationSearchParams { sender?: string; // target_recipient exact/partial match txHash?: string; // tx_hash exact/partial match eventId?: string; // event_id exact/partial match - status?: string; // scheduled_notifications.status - type?: string; // notification_type + status?: string; // scheduled_notifications.status / processed_events.status + type?: string; // notification_type (discord|email|webhook|sms) + startDate?: string; // inclusive lower bound on created_at / processed_at (YYYY-MM-DD or ISO) + endDate?: string; // inclusive upper bound on created_at / processed_at (YYYY-MM-DD or ISO) limit?: number; offset?: number; } +/** Normalize a date filter so YYYY-MM-DD covers the full UTC day. */ +export function normalizeSearchDateBound(value: string, bound: 'start' | 'end'): string { + if (value.includes('T')) return value; + return bound === 'start' ? `${value}T00:00:00.000Z` : `${value}T23:59:59.999Z`; +} + export interface NotificationSearchResult { id: number; source: 'scheduled' | 'processed'; @@ -100,8 +108,16 @@ export class NotificationSearchService { queryParams.push(params.status.toUpperCase()); } if (params.type) { - conditions.push('notification_type LIKE ?'); - queryParams.push(`%${params.type}%`); + conditions.push('LOWER(notification_type) = ?'); + queryParams.push(params.type.toLowerCase()); + } + if (params.startDate) { + conditions.push('created_at >= ?'); + queryParams.push(normalizeSearchDateBound(params.startDate, 'start')); + } + if (params.endDate) { + conditions.push('created_at <= ?'); + queryParams.push(normalizeSearchDateBound(params.endDate, 'end')); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; @@ -173,8 +189,21 @@ export class NotificationSearchService { conditions.push('status = ?'); queryParams.push(params.status.toUpperCase()); } + if (params.type) { + // processed_events store channel/event type in event_type + conditions.push('LOWER(event_type) = ?'); + queryParams.push(params.type.toLowerCase()); + } + if (params.startDate) { + conditions.push('processed_at >= ?'); + queryParams.push(normalizeSearchDateBound(params.startDate, 'start')); + } + if (params.endDate) { + conditions.push('processed_at <= ?'); + queryParams.push(normalizeSearchDateBound(params.endDate, 'end')); + } - // sender / type don't apply to processed_events, skip those params + // sender does not apply to processed_events const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';