(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() {
- Status
+ Delivery status
setStatus(e.target.value)}
+ aria-label="Filter by delivery status"
>
- {STATUS_OPTIONS.map((s) => (
- {s || 'All statuses'}
+ {NOTIFICATION_DELIVERY_STATUS_OPTIONS.map(({ value, label }) => (
+ {label}
))}
- Type
- Notification type
+ setType(e.target.value)}
+ aria-label="Filter by notification type"
+ >
+ {NOTIFICATION_TYPE_OPTIONS.map(({ value, label }) => (
+ {label}
+ ))}
+
+
+
+
+ From
+ setDateFrom(e.target.value)}
+ aria-label="Filter from date"
+ />
+
+
+
+ To
+ 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 ')}` : '';