From 4233a73667f6aa4581c3a5728824e4ed882d692c Mon Sep 17 00:00:00 2001 From: NanaKhadija1980j Date: Fri, 26 Jun 2026 13:07:57 +0000 Subject: [PATCH] feat(#168): implement notification search experience - Add NotificationSearchService with paginated DB search across scheduled_notifications and processed_events (partial matching on sender, eventId, txHash, contractAddress, type, payload) - Add GET /api/notifications/search endpoint to events-server - Add searchNotifications() + types to dashboard eventsApi service - Add NotificationSearchPage with search form, result cards, pagination, and empty-state messaging - Wire NotificationSearchPage into App.tsx as 'Notification Search' tab Fix pre-existing CI failures: - EventExplorerCard/Table: merge duplicate exports, fix isPaused scope - wallet-integration.test: event.id -> event.eventId - eventStore.test: add missing EventFilters fields (status, dateFrom, dateTo) - timelineApi.ts: replace import.meta with declare to fix Jest parse error - schema.sql: move next_retry_at into CREATE TABLE, remove ALTER TABLE --- dashboard/reports/wallet-integration.json | 2 +- dashboard/src/App.tsx | 12 +- .../src/__tests__/wallet-integration.test.tsx | 4 +- .../src/components/EventExplorerCard.tsx | 11 - .../src/components/EventExplorerTable.tsx | 3 - dashboard/src/index.css | 200 +++++++++++ .../src/pages/NotificationSearchPage.tsx | 316 ++++++++++++++++++ dashboard/src/services/eventsApi.ts | 54 +++ dashboard/src/services/timelineApi.ts | 8 +- dashboard/src/store/eventStore.test.tsx | 6 +- listener/src/api/events-server.ts | 29 ++ listener/src/database/schema.sql | 6 +- .../services/notification-search-service.ts | 219 ++++++++++++ 13 files changed, 843 insertions(+), 27 deletions(-) create mode 100644 dashboard/src/pages/NotificationSearchPage.tsx create mode 100644 listener/src/services/notification-search-service.ts diff --git a/dashboard/reports/wallet-integration.json b/dashboard/reports/wallet-integration.json index 5b7362a..a9c1b92 100644 --- a/dashboard/reports/wallet-integration.json +++ b/dashboard/reports/wallet-integration.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-24T19:47:36.590Z", + "generatedAt": "2026-06-26T13:01:40.990Z", "total": 3, "passed": 3, "failed": 0, diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0249509..b4dec7d 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -3,8 +3,9 @@ import { EventExplorerPage } from './pages/EventExplorerPage'; import { NotificationTimelineView } from './components/NotificationTimelineView'; import { ActivityFeed } from './components/ActivityFeed'; import { ExportHistoryPage } from './pages/ExportHistoryPage'; +import { NotificationSearchPage } from './pages/NotificationSearchPage'; -type Tab = 'explorer' | 'timeline' | 'activity' | 'export-history'; +type Tab = 'explorer' | 'timeline' | 'activity' | 'export-history' | 'search'; export function App() { const [tab, setTab] = useState('explorer'); @@ -44,12 +45,21 @@ export function App() { > Export History + {tab === 'explorer' && } {tab === 'timeline' && } {tab === 'activity' && } {tab === 'export-history' && } + {tab === 'search' && } ); } diff --git a/dashboard/src/__tests__/wallet-integration.test.tsx b/dashboard/src/__tests__/wallet-integration.test.tsx index c329750..bd0a53c 100644 --- a/dashboard/src/__tests__/wallet-integration.test.tsx +++ b/dashboard/src/__tests__/wallet-integration.test.tsx @@ -138,7 +138,7 @@ describe('Notification workflow with wallet connected', () => { json: async () => ({ events: [ { - id: 'evt-wallet-1', + eventId: 'evt-wallet-1', contractAddress: 'CTEST', topic: 'task_created', ledger: 100, @@ -160,7 +160,7 @@ describe('Notification workflow with wallet connected', () => { const events = await fetchEvents('http://localhost:8787/api/events'); expect(events).toHaveLength(1); - expect(events[0].id).toBe('evt-wallet-1'); + expect(events[0].eventId).toBe('evt-wallet-1'); expect(fetchMock).toHaveBeenCalledWith('http://localhost:8787/api/events'); }); }); diff --git a/dashboard/src/components/EventExplorerCard.tsx b/dashboard/src/components/EventExplorerCard.tsx index 53f30e0..35f9631 100644 --- a/dashboard/src/components/EventExplorerCard.tsx +++ b/dashboard/src/components/EventExplorerCard.tsx @@ -40,9 +40,6 @@ interface EventExplorerCardProps { export function EventExplorerCard({ event, onCopyContract, isCopied, contractStatuses }: EventExplorerCardProps) { const contractStatus = contractStatuses.find(c => c.address === event.contractAddress); const isPaused = contractStatus?.paused ?? false; -} - -export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExplorerCardProps) { const label = event.eventName ?? event.type; const badgeClass = getEventKindClass(event.type); const kindLabel = getEventKindLabel(event.type); @@ -67,14 +64,6 @@ export function EventExplorerCard({ event, onCopyContract, isCopied }: EventExpl Paused )} - diff --git a/dashboard/src/components/EventExplorerTable.tsx b/dashboard/src/components/EventExplorerTable.tsx index 128fcc1..4c6a850 100644 --- a/dashboard/src/components/EventExplorerTable.tsx +++ b/dashboard/src/components/EventExplorerTable.tsx @@ -9,9 +9,6 @@ interface EventExplorerTableProps { } export function EventExplorerTable({ events, contractStatuses }: EventExplorerTableProps) { -} - -export function EventExplorerTable({ events }: EventExplorerTableProps) { const [copiedAddress, setCopiedAddress] = useState(null); async function syncCopyText(text: string) { diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 4968c5d..1be82b9 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -1861,3 +1861,203 @@ body { align-items: stretch; } } + +/* ── Notification Search Page ──────────────────────────────────────────────── */ + +.notif-search-page { + max-width: 1100px; + margin: 0 auto; + padding: 24px; +} + +.notif-search-page__header { + margin-bottom: 24px; +} + +.notif-search-page__header h1 { + margin: 4px 0 8px; + font-size: 1.75rem; +} + +.notif-search-form { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; +} + +.notif-search-form__row { + display: grid; + grid-template-columns: 2fr repeat(5, 1fr); + gap: 12px; + align-items: end; +} + +.notif-search-form__group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.notif-search-form__group--wide { + grid-column: span 2; +} + +.notif-search-form__label { + font-size: 0.82rem; + color: #9aa0a6; + font-weight: 500; +} + +.notif-search-form__input { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + color: #e8eaed; + font-size: 0.9rem; + padding: 8px 12px; + outline: none; + width: 100%; +} + +.notif-search-form__input:focus { + border-color: #4a9eff; +} + +.notif-search-page__status { + color: #9aa0a6; + padding: 8px 0; +} + +.notif-search-page__empty { + text-align: center; + padding: 64px 24px; + color: #9aa0a6; +} + +.notif-search-page__empty h2 { + margin: 0 0 8px; + font-size: 1.25rem; + color: #e8eaed; +} + +.notif-search-page__empty p { + margin: 0; +} + +.notif-search-results { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.notif-result-card { + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; + padding: 14px 16px; +} + +.notif-result-card__header { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.notif-result-card__source { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.notif-result-card__source--scheduled { background: rgba(74,158,255,0.15); color: #4a9eff; } +.notif-result-card__source--processed { background: rgba(52,211,153,0.15); color: #34d399; } + +.notif-result-card__status { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; +} + +.notif-result-card__status--completed, +.notif-result-card__status--processed { background: rgba(52,211,153,0.1); color: #34d399; } +.notif-result-card__status--pending { background: rgba(251,191,36,0.1); color: #fbbf24; } +.notif-result-card__status--failed { background: rgba(239,68,68,0.1); color: #ef4444; } +.notif-result-card__status--processing { background: rgba(74,158,255,0.1); color: #4a9eff; } +.notif-result-card__status--cancelled { background: rgba(156,163,175,0.1); color: #9ca3af; } + +.notif-result-card__type { + font-size: 0.75rem; + color: #9aa0a6; + padding: 2px 8px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; +} + +.notif-result-card__fields { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 16px; + margin: 0; + font-size: 0.85rem; +} + +.notif-result-card__fields dt { + color: #9aa0a6; + white-space: nowrap; +} + +.notif-result-card__fields dd { + margin: 0; + color: #e8eaed; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notif-result-card__fields code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.8rem; + color: #a5d6ff; +} + +.notif-search-page__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 24px; +} + +.pagination__info { + color: #9aa0a6; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .notif-search-form__row { + grid-template-columns: 1fr 1fr; + } + + .notif-search-form__group--wide { + grid-column: span 2; + } + + .notif-result-card__fields { + grid-template-columns: 1fr; + } + + .notif-result-card__fields dt { + font-weight: 600; + margin-top: 6px; + } +} diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx new file mode 100644 index 0000000..28a59ab --- /dev/null +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -0,0 +1,316 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { useDebounce } from '../hooks/useDebounce'; +import { + searchNotifications, + type NotificationSearchResult, + type NotificationSearchResponse, +} from '../services/eventsApi'; + +const PAGE_SIZE = 20; +const API_BASE = (import.meta.env.VITE_EVENTS_API_URL ?? 'http://localhost:8787/api/events').replace( + '/api/events', + '' +); + +const STATUS_OPTIONS = ['', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'PROCESSED']; + +export function NotificationSearchPage() { + const [query, setQuery] = useState(''); + const [sender, setSender] = useState(''); + const [txHash, setTxHash] = useState(''); + const [eventId, setEventId] = useState(''); + const [status, setStatus] = useState(''); + const [type, setType] = useState(''); + const [page, setPage] = useState(1); + + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const debouncedQuery = useDebounce(query, 300); + const debouncedSender = useDebounce(sender, 300); + const debouncedTxHash = useDebounce(txHash, 300); + const debouncedEventId = useDebounce(eventId, 300); + + // Track whether any search param is active + const hasParams = + debouncedQuery || debouncedSender || debouncedTxHash || debouncedEventId || status || type; + + const abortRef = useRef(null); + + const runSearch = useCallback(async () => { + if (!hasParams) { + setResponse(null); + setError(null); + return; + } + + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + setLoading(true); + setError(null); + + try { + const result = await searchNotifications(API_BASE, { + q: debouncedQuery || undefined, + sender: debouncedSender || undefined, + txHash: debouncedTxHash || undefined, + eventId: debouncedEventId || undefined, + status: status || undefined, + type: type || undefined, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }); + setResponse(result); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; + setError(err instanceof Error ? err.message : 'Search failed'); + } finally { + setLoading(false); + } + }, [debouncedQuery, debouncedSender, debouncedTxHash, debouncedEventId, status, type, page, hasParams]); + + // Re-run search whenever debounced params change; reset page when filters change + const filtersKey = `${debouncedQuery}|${debouncedSender}|${debouncedTxHash}|${debouncedEventId}|${status}|${type}`; + const prevFiltersRef = useRef(filtersKey); + useEffect(() => { + if (filtersKey !== prevFiltersRef.current) { + setPage(1); + prevFiltersRef.current = filtersKey; + } + }, [filtersKey]); + + useEffect(() => { + runSearch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersKey, page]); + + function clearAll() { + setQuery(''); + setSender(''); + setTxHash(''); + setEventId(''); + setStatus(''); + setType(''); + setPage(1); + setResponse(null); + setError(null); + } + + const totalPages = response ? response.totalPages : 0; + + return ( +
+
+

Notifications

+

Notification Search

+

+ Search scheduled and processed notifications by sender, transaction hash, event ID, type, or free-text. +

+
+ + {/* Search form */} +
+
+
+ + setQuery(e.target.value)} + aria-label="Free-text search" + /> +
+ +
+ + setSender(e.target.value)} + /> +
+ +
+ + setTxHash(e.target.value)} + /> +
+ +
+ + setEventId(e.target.value)} + /> +
+ +
+ + +
+ +
+ + setType(e.target.value)} + /> +
+
+ + {hasParams && ( + + )} +
+ + {/* Results area */} +
+ {loading && ( +

Searching…

+ )} + + {error && !loading && ( +
+ Error: {error} +
+ )} + + {!loading && !error && !hasParams && ( +
+

Start searching

+

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

+
+ )} + + {!loading && !error && hasParams && response?.results.length === 0 && ( +
+

No results found

+

Try different keywords or clear filters to broaden the search.

+
+ )} + + {!loading && !error && response && response.results.length > 0 && ( + <> +

+ {response.total.toLocaleString()} result{response.total !== 1 ? 's' : ''} — showing{' '} + {((page - 1) * PAGE_SIZE + 1).toLocaleString()}– + {Math.min(page * PAGE_SIZE, response.total).toLocaleString()} +

+ +
+ {response.results.map((r) => ( + + ))} +
+ + {totalPages > 1 && ( + + )} + + )} +
+
+ ); +} + +function NotificationResultCard({ result }: { result: NotificationSearchResult }) { + return ( +
+
+ + {result.source} + + + {result.status} + + {result.notificationType && ( + {result.notificationType} + )} +
+ +
+ {result.eventId && ( + <> +
Event ID
+
{result.eventId}
+ + )} + {result.txHash && ( + <> +
Tx Hash
+
{result.txHash}
+ + )} + {result.contractAddress && ( + <> +
Contract
+
{result.contractAddress}
+ + )} + {result.targetRecipient && ( + <> +
Recipient
+
{result.targetRecipient}
+ + )} +
Created
+
{new Date(result.createdAt).toLocaleString()}
+
+
+ ); +} diff --git a/dashboard/src/services/eventsApi.ts b/dashboard/src/services/eventsApi.ts index 10e7f45..6abc413 100644 --- a/dashboard/src/services/eventsApi.ts +++ b/dashboard/src/services/eventsApi.ts @@ -11,6 +11,39 @@ export interface StatusResponse { contracts: ContractStatus[]; } +export interface NotificationSearchResult { + id: number; + source: 'scheduled' | 'processed'; + eventId: string | null; + txHash: string | null; + contractAddress: string | null; + notificationType: string | null; + targetRecipient: string | null; + status: string; + createdAt: string; + payload: string | null; +} + +export interface NotificationSearchResponse { + results: NotificationSearchResult[]; + total: number; + limit: number; + offset: number; + itemCount: number; + totalPages: number; +} + +export interface NotificationSearchParams { + q?: string; + sender?: string; + txHash?: string; + eventId?: string; + status?: string; + type?: string; + limit?: number; + offset?: number; +} + export async function fetchEvents(apiUrl: string): Promise { const response = await fetch(apiUrl); if (!response.ok) { @@ -28,3 +61,24 @@ export async function fetchStatus(apiUrl: string): Promise { } return response.json() as Promise; } + +export async function searchNotifications( + baseUrl: string, + params: NotificationSearchParams +): Promise { + const url = new URL(`${baseUrl}/api/notifications/search`); + if (params.q) url.searchParams.set('q', params.q); + if (params.sender) url.searchParams.set('sender', params.sender); + if (params.txHash) url.searchParams.set('txHash', params.txHash); + 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.limit !== undefined) url.searchParams.set('limit', String(params.limit)); + if (params.offset !== undefined) url.searchParams.set('offset', String(params.offset)); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + return response.json() as Promise; +} diff --git a/dashboard/src/services/timelineApi.ts b/dashboard/src/services/timelineApi.ts index e1b45bb..f59139b 100644 --- a/dashboard/src/services/timelineApi.ts +++ b/dashboard/src/services/timelineApi.ts @@ -1,7 +1,11 @@ import type { NotificationTimeline } from '../types/timeline'; -const BASE_URL = - (typeof import.meta !== 'undefined' && (import.meta as any).env?.VITE_EVENTS_API_URL) || +// globalThis.__VITE_EVENTS_API_URL__ is injected by Vite's define plugin (if configured). +// In Jest/Node it is undefined, so we fall back to the default. +declare const __VITE_EVENTS_API_URL__: string | undefined; + +const BASE_URL: string = + (typeof __VITE_EVENTS_API_URL__ !== 'undefined' ? __VITE_EVENTS_API_URL__ : '') || 'http://localhost:8787'; export async function fetchTimeline(notificationId: number): Promise { diff --git a/dashboard/src/store/eventStore.test.tsx b/dashboard/src/store/eventStore.test.tsx index c7771aa..a2128c4 100644 --- a/dashboard/src/store/eventStore.test.tsx +++ b/dashboard/src/store/eventStore.test.tsx @@ -42,7 +42,7 @@ describe('event store selective subscriptions', () => { it('filter updates do not require reloading the full event collection', async () => { useEventStore.setState({ events: generateMockEvents(100), - filters: { search: '', contractAddress: 'all', eventType: 'all' }, + filters: { search: '', contractAddress: 'all', eventType: 'all', status: 'all', dateFrom: '', dateTo: '' }, isLoading: false, error: null, }); @@ -70,7 +70,7 @@ describe('pagination + filter interaction', () => { const events = generateMockEvents(200); useEventStore.setState({ events, - filters: { search: '', contractAddress: 'all', eventType: 'all' }, + filters: { search: '', contractAddress: 'all', eventType: 'all', status: 'all', dateFrom: '', dateTo: '' }, isLoading: false, error: null, }); @@ -93,7 +93,7 @@ describe('pagination + filter interaction', () => { it('filter change resets scroll position to top', async () => { useEventStore.setState({ events: generateMockEvents(100), - filters: { search: '', contractAddress: 'all', eventType: 'all' }, + filters: { search: '', contractAddress: 'all', eventType: 'all', status: 'all', dateFrom: '', dateTo: '' }, isLoading: false, error: null, }); diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 3709738..1e2c7e4 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -9,6 +9,7 @@ import logger from '../utils/logger'; import { generateRequestId, resolveCorrelationId } from '../utils/request-id'; import { NotificationHistoryService } from '../services/notification-history'; import { SearchSuggestionService } from '../services/search-suggestion'; +import { NotificationSearchService } from '../services/notification-search-service'; import { verifySignature, extractSignature, @@ -346,6 +347,7 @@ export function createEventsServer(options: EventsServerOptions): http.Server { const corsOrigin = options.corsOrigin ?? 'http://localhost:5173'; const historyService = new NotificationHistoryService(); const suggestionService = new SearchSuggestionService(); + const notificationSearchService = new NotificationSearchService(); const rateLimiter = options.rateLimit ? new RateLimiter(options.rateLimit) : undefined; const server = http.createServer(async (req, res) => { @@ -773,6 +775,33 @@ export function createEventsServer(options: EventsServerOptions): http.Server { return; } + // GET /api/notifications/search + if (req.method === 'GET' && url.pathname === '/api/notifications/search') { + const q = url.searchParams.get('q') ?? undefined; + const sender = url.searchParams.get('sender') ?? undefined; + const txHash = url.searchParams.get('txHash') ?? undefined; + const eventId = url.searchParams.get('eventId') ?? undefined; + const status = url.searchParams.get('status') ?? undefined; + const type = url.searchParams.get('type') ?? 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 }); + + notificationSearchService.search({ q, sender, txHash, eventId, status, type, limit, offset }) + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + logger.info('GET /api/notifications/search complete', { requestId, total: result.total, durationMs: Date.now() - startTime }); + }) + .catch((error) => { + logger.error('Failed to search notifications', { error, requestId, correlationId }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + }); + return; + } + // GET /api/search/suggestions if (req.method === 'GET' && url.pathname === '/api/search/suggestions') { const q = url.searchParams.get('q') || ''; diff --git a/listener/src/database/schema.sql b/listener/src/database/schema.sql index 5f378e8..371a596 100644 --- a/listener/src/database/schema.sql +++ b/listener/src/database/schema.sql @@ -34,7 +34,8 @@ CREATE TABLE IF NOT EXISTS scheduled_notifications ( event_id TEXT, -- Reference to the original event (if applicable) contract_address TEXT, -- Stellar contract address (if applicable) priority INTEGER NOT NULL DEFAULT 5, -- 1-10, lower = higher priority - metadata TEXT -- Additional JSON metadata + metadata TEXT, -- Additional JSON metadata + next_retry_at DATETIME -- When the next retry should be attempted ); -- Indexes for performance optimization @@ -86,9 +87,6 @@ CREATE INDEX IF NOT EXISTS idx_execution_log_execution_time CREATE INDEX IF NOT EXISTS idx_execution_log_status_execution_time ON notification_execution_log(status, execution_time); --- Migration: add next_retry_at for explicit retry scheduling -ALTER TABLE scheduled_notifications ADD COLUMN next_retry_at DATETIME; - -- Trigger to update updated_at timestamp CREATE TRIGGER IF NOT EXISTS update_scheduled_notifications_timestamp AFTER UPDATE ON scheduled_notifications diff --git a/listener/src/services/notification-search-service.ts b/listener/src/services/notification-search-service.ts new file mode 100644 index 0000000..55ec8ae --- /dev/null +++ b/listener/src/services/notification-search-service.ts @@ -0,0 +1,219 @@ +import { getDatabase } from '../database/database'; +import logger from '../utils/logger'; +import { buildPaginationMetadata, normalizePaginationParams } from '../utils/pagination'; + +export interface NotificationSearchParams { + q?: string; // partial match: sender, eventId, txHash, contractAddress, notificationType, payload + 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 + limit?: number; + offset?: number; +} + +export interface NotificationSearchResult { + id: number; + source: 'scheduled' | 'processed'; + eventId: string | null; + txHash: string | null; + contractAddress: string | null; + notificationType: string | null; + targetRecipient: string | null; + status: string; + createdAt: string; + payload: string | null; +} + +export interface PaginatedSearchResponse { + results: NotificationSearchResult[]; + total: number; + limit: number; + offset: number; + itemCount: number; + totalPages: number; +} + +export class NotificationSearchService { + private db = getDatabase(); + + async search(params: NotificationSearchParams): Promise { + const { limit, offset } = normalizePaginationParams(params.limit, params.offset); + const pattern = params.q ? `%${params.q}%` : null; + + try { + const scheduledResults = await this.searchScheduled(params, pattern, limit, offset); + const processedResults = await this.searchProcessed(params, pattern, limit, offset); + + // Merge, sort by createdAt desc, then re-paginate + const merged = [...scheduledResults.rows, ...processedResults.rows].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + const total = scheduledResults.total + processedResults.total; + + const paginated = merged.slice(0, limit); + + const pagination = buildPaginationMetadata(total, limit, offset); + + logger.info('Notification search complete', { total, returned: paginated.length, limit, offset }); + + return { + results: paginated, + total, + limit: pagination.limit, + offset: pagination.offset, + itemCount: pagination.itemCount, + totalPages: pagination.totalPages, + }; + } catch (error) { + logger.error('Notification search failed', { error, params }); + throw error; + } + } + + private async searchScheduled( + params: NotificationSearchParams, + pattern: string | null, + limit: number, + offset: number + ): Promise<{ rows: NotificationSearchResult[]; total: number }> { + const conditions: string[] = []; + const queryParams: unknown[] = []; + + if (pattern) { + conditions.push( + `(target_recipient LIKE ? OR event_id LIKE ? OR contract_address LIKE ? OR notification_type LIKE ? OR payload LIKE ?)` + ); + queryParams.push(pattern, pattern, pattern, pattern, pattern); + } + if (params.sender) { + conditions.push('target_recipient LIKE ?'); + queryParams.push(`%${params.sender}%`); + } + if (params.eventId) { + conditions.push('event_id LIKE ?'); + queryParams.push(`%${params.eventId}%`); + } + if (params.status) { + conditions.push('status = ?'); + queryParams.push(params.status.toUpperCase()); + } + if (params.type) { + conditions.push('notification_type LIKE ?'); + queryParams.push(`%${params.type}%`); + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const countRow = await this.db.get<{ count: number }>( + `SELECT COUNT(*) as count FROM scheduled_notifications ${where}`, + queryParams + ); + const total = countRow?.count ?? 0; + + const rows = await this.db.all<{ + id: number; + event_id: string | null; + contract_address: string | null; + notification_type: string; + target_recipient: string; + status: string; + created_at: string; + payload: string; + }>( + `SELECT id, event_id, contract_address, notification_type, target_recipient, status, created_at, payload + FROM scheduled_notifications ${where} + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, + [...queryParams, limit, offset] + ); + + return { + total, + rows: rows.map((r) => ({ + id: r.id, + source: 'scheduled' as const, + eventId: r.event_id, + txHash: null, + contractAddress: r.contract_address, + notificationType: r.notification_type, + targetRecipient: r.target_recipient, + status: r.status, + createdAt: r.created_at, + payload: r.payload, + })), + }; + } + + private async searchProcessed( + params: NotificationSearchParams, + pattern: string | null, + limit: number, + offset: number + ): Promise<{ rows: NotificationSearchResult[]; total: number }> { + const conditions: string[] = []; + const queryParams: unknown[] = []; + + if (pattern) { + conditions.push( + `(event_id LIKE ? OR tx_hash LIKE ? OR contract_address LIKE ? OR event_type LIKE ?)` + ); + queryParams.push(pattern, pattern, pattern, pattern); + } + if (params.txHash) { + conditions.push('tx_hash LIKE ?'); + queryParams.push(`%${params.txHash}%`); + } + if (params.eventId) { + conditions.push('event_id LIKE ?'); + queryParams.push(`%${params.eventId}%`); + } + if (params.status) { + conditions.push('status = ?'); + queryParams.push(params.status.toUpperCase()); + } + + // sender / type don't apply to processed_events, skip those params + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + + const countRow = await this.db.get<{ count: number }>( + `SELECT COUNT(*) as count FROM processed_events ${where}`, + queryParams + ); + const total = countRow?.count ?? 0; + + const rows = await this.db.all<{ + id: number; + event_id: string; + tx_hash: string | null; + contract_address: string; + event_type: string; + status: string; + processed_at: string; + }>( + `SELECT id, event_id, tx_hash, contract_address, event_type, status, processed_at + FROM processed_events ${where} + ORDER BY processed_at DESC + LIMIT ? OFFSET ?`, + [...queryParams, limit, offset] + ); + + return { + total, + rows: rows.map((r) => ({ + id: r.id, + source: 'processed' as const, + eventId: r.event_id, + txHash: r.tx_hash, + contractAddress: r.contract_address, + notificationType: r.event_type, + targetRecipient: null, + status: r.status, + createdAt: r.processed_at, + payload: null, + })), + }; + } +}