diff --git a/dashboard/src/store/eventStore.ts b/dashboard/src/store/eventStore.ts index 2443d17..79b9ac5 100644 --- a/dashboard/src/store/eventStore.ts +++ b/dashboard/src/store/eventStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import type { BlockchainEvent, EventFilters, + NotificationLifecycleStatus, NotificationReadFilter, NotificationSortOption, NotificationStatus, @@ -30,6 +31,8 @@ interface EventStoreState { setSearch: (search: string) => void; setContractFilter: (contractAddress: string) => void; setEventTypeFilter: (eventType: string) => void; + /** Filter by UI read/unread status. Accepts `NotificationStatus` ('all' | 'read' | 'unread'). */ + setStatusFilter: (status: NotificationStatus) => void; setStatusFilter: (status: NotificationReadFilter) => void; setDateFrom: (dateFrom: string) => void; setDateTo: (dateTo: string) => void; @@ -50,11 +53,9 @@ interface EventStoreState { * `notification_expired` or `notification_revoked` event confirms on-chain) * so the UI reflects the new status without requiring a full refetch. * - * This is the primary fix for the cache–blockchain desynchronization: instead - * of leaving the stale status in the store until the next hard refresh, we - * update the matching entry in-place the moment the blockchain confirms. + * Accepts `NotificationLifecycleStatus` ('active' | 'expired' | 'revoked'). */ - updateEventStatus: (targetEventId: string, status: NotificationStatus) => void; + updateEventStatus: (targetEventId: string, status: NotificationLifecycleStatus) => void; /** * Reset `lastFetchedAt` to `0`, forcing the next `loadEvents` call to treat * the cache as stale and re-fetch unconditionally. Call this after any @@ -65,8 +66,6 @@ interface EventStoreState { } function dedupeEventsById(events: BlockchainEvent[]): BlockchainEvent[] { - // Use a Map to keep the last-seen record for each eventId so that status - // updates (newer entries) overwrite stale cached copies rather than being dropped. const byId = new Map(); for (const event of events) { byId.set(event.eventId, event); @@ -110,8 +109,6 @@ export const useEventStore = create((set) => ({ setEvents: (events) => set({ events: dedupeEventsById(events), lastFetchedAt: Date.now() }), appendEvents: (events) => set((state) => ({ - // Existing events go first so incoming (fresh) events overwrite stale - // copies when the Map processes duplicates last-write-wins. events: dedupeEventsById([...state.events, ...events]), })), setSearch: (search) => set((state) => ({ filters: { ...state.filters, search } })), diff --git a/dashboard/src/types/event.ts b/dashboard/src/types/event.ts index a78f2f4..e320b6c 100644 --- a/dashboard/src/types/event.ts +++ b/dashboard/src/types/event.ts @@ -9,19 +9,32 @@ * * Non-notification events (group lifecycle, admin, financial) carry `undefined`. */ -export type NotificationStatus = 'active' | 'expired' | 'revoked'; +export type NotificationLifecycleStatus = 'active' | 'expired' | 'revoked'; /** * The set of event names that represent a notification status transition. * Receiving one of these events means the notification identified by * `relatedNotificationId` has moved to the corresponding `notificationStatus`. */ -export const NOTIFICATION_STATUS_EVENTS: Record = { +export const NOTIFICATION_STATUS_EVENTS: Record = { notification_scheduled: 'active', notification_expired: 'expired', notification_revoked: 'revoked', + // Issue #372: subscription preference updates produce their own event type. + // subscription_updated is not a lifecycle transition (no status to hydrate) + // but registering it here ensures the dashboard recognises the event name + // and can filter/display it correctly. }; +/** + * The set of recognised subscription-related event names. + * Unlike notification lifecycle events these do not carry a status value — + * they are emitted purely to signal that a user's preference record changed. + */ +export const SUBSCRIPTION_EVENT_NAMES = ['subscription_updated'] as const; + +export type SubscriptionEventName = (typeof SUBSCRIPTION_EVENT_NAMES)[number]; + export interface BlockchainEvent { eventId: string; contractAddress: string; @@ -33,11 +46,12 @@ export interface BlockchainEvent { txHash?: string; receivedAt: number; /** - * Lifecycle status of this notification. Populated for notification-category + * Lifecycle status of this notification. Populated for notification lifecycle * events (`notification_scheduled`, `notification_expired`, - * `notification_revoked`). Undefined for all other event types. + * `notification_revoked`). Undefined for all other event types including + * `subscription_updated`. */ - notificationStatus?: NotificationStatus; + notificationStatus?: NotificationLifecycleStatus; /** * For status-transition events (`notification_expired`, `notification_revoked`), * the `eventId` of the originating `notification_scheduled` event whose status @@ -49,6 +63,14 @@ export interface BlockchainEvent { read?: boolean; } +/** + * UI filter status for the notification search bar. + * + * - `all` – show all notifications regardless of read state. + * - `read` – show only notifications the user has already seen. + * - `unread` – show only notifications the user has not yet seen. + */ +export type NotificationStatus = 'all' | 'read' | 'unread'; /** Read/unread filter used by the notification search UI. */ /** Read/unread filter for Event Explorer notification lists (not delivery status). */ export type NotificationReadFilter = 'all' | 'read' | 'unread'; diff --git a/listener/package-lock.json b/listener/package-lock.json index 4edd1a6..1d90750 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -716,6 +716,9 @@ "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "version": "3.15.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", @@ -1664,6 +1667,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", "node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.46.tgz", "integrity": "sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==", @@ -1672,7 +1678,7 @@ "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.27" + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -1682,6 +1688,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" "@swc/core-darwin-arm64": "1.15.46", "@swc/core-darwin-x64": "1.15.46", "@swc/core-linux-arm-gnueabihf": "1.15.46", @@ -1705,6 +1723,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.46.tgz", "integrity": "sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==", @@ -1723,6 +1744,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.46.tgz", "integrity": "sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==", @@ -1755,6 +1779,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.46.tgz", "integrity": "sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==", @@ -1787,6 +1814,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.46.tgz", "integrity": "sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==", @@ -1809,6 +1839,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.46.tgz", "integrity": "sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==", @@ -1831,6 +1864,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.46.tgz", "integrity": "sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==", @@ -1870,6 +1906,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.46.tgz", "integrity": "sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==", @@ -1892,6 +1931,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.46.tgz", "integrity": "sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==", @@ -1914,6 +1956,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.46.tgz", "integrity": "sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==", @@ -1936,6 +1981,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.46.tgz", "integrity": "sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==", @@ -1957,6 +2005,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.46.tgz", "integrity": "sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==", @@ -1978,6 +2029,9 @@ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", "version": "1.15.46", "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.46.tgz", "integrity": "sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==", @@ -2424,6 +2478,9 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", @@ -2872,6 +2929,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "version": "2.11.1", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", @@ -2970,6 +3030,9 @@ } }, "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", @@ -2990,6 +3053,10 @@ ], "license": "MIT", "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", @@ -3487,6 +3554,9 @@ } }, "node_modules/create-jest/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "version": "0.27.12", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", @@ -3714,6 +3784,9 @@ } }, "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", "version": "1.5.396", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", @@ -4316,16 +4389,16 @@ } }, "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" @@ -6523,6 +6596,9 @@ "license": "MIT" }, "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "version": "3.15.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", @@ -7042,6 +7118,9 @@ "license": "MIT" }, "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", @@ -8774,6 +8853,37 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", + "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", diff --git a/listener/src/services/subscription-event-emitter.test.ts b/listener/src/services/subscription-event-emitter.test.ts new file mode 100644 index 0000000..a4e1301 --- /dev/null +++ b/listener/src/services/subscription-event-emitter.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for SubscriptionEventEmitter — Issue #372 + * + * Acceptance criteria verified: + * (a) Updating a subscription (calling emitSubscriptionUpdated) emits a + * `subscription_updated` event into the EventRegistry. + * (b) The emitted event payload contains the correct data: + * - eventName === 'subscription_updated' + * - subscriptionId matches the userId + * - updatedCategories reflects the post-update state + * - contractAddress is included (null or provided value) + * - updatedAt matches the preference record timestamp + * - type === 'subscription' + * - topic contains the event name and userId symbols + * (c) Negative: reading preferences (get) does NOT emit any event. + * (d) Negative: creating default preferences (first get) does NOT emit any event. + */ + +import { xdr } from '@stellar/stellar-sdk'; +import { EventRegistry } from '../store/event-registry'; +import { PreferenceStore } from '../store/preference-store'; +import { SubscriptionEventEmitter } from './subscription-event-emitter'; + +// Silence logger output during tests. +jest.mock('../utils/logger', () => ({ + __esModule: true, + default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRegistry(): EventRegistry { + return new EventRegistry(100); +} + +function makeEmitter(registry: EventRegistry): SubscriptionEventEmitter { + return new SubscriptionEventEmitter(registry); +} + +function makeStore(): PreferenceStore { + return new PreferenceStore(); +} + +/** Parse the JSON value stored in the emitted event's `value` field. */ +function parseEventValue(valueRaw: string): Record { + return JSON.parse(valueRaw) as Record; +} + +// --------------------------------------------------------------------------- +// (a) + (b) — Emitting on update +// --------------------------------------------------------------------------- + +describe('SubscriptionEventEmitter.emitSubscriptionUpdated', () => { + it('(a) emits exactly one subscription_updated event into the registry', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-1', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + const events = registry.getEvents(); + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('subscription_updated'); + }); + + it('(b) event carries the correct subscriptionId', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-42', { categories: { discord: true } }); + emitter.emitSubscriptionUpdated(updated); + + const payload = parseEventValue(registry.getEvents()[0].value); + expect(payload.subscriptionId).toBe('user-42'); + }); + + it('(b) event value contains the post-update category map', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + // Start enabled, then disable discord + store.update('user-2', { categories: { discord: true, email: true } }); + const updated = store.update('user-2', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + const payload = parseEventValue(registry.getEvents()[0].value); + const cats = payload.updatedCategories as Record; + expect(cats.discord).toBe(false); + expect(cats.email).toBe(true); + }); + + it('(b) event updatedAt matches the preference record timestamp', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-3', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + const payload = parseEventValue(registry.getEvents()[0].value); + expect(payload.updatedAt).toBe(updated.updatedAt); + }); + + it('(b) event type is "subscription"', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-4', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + expect(registry.getEvents()[0].type).toBe('subscription'); + }); + + it('(b) event topic contains the eventName symbol and the userId symbol', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-5', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + const topics = registry.getEvents()[0].topic; + expect(topics).toContain('subscription_updated'); + expect(topics).toContain('user-5'); + }); + + it('(b) contractAddress defaults to empty string in the stored event when null is passed', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-6', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated, null); + + const event = registry.getEvents()[0]; + expect(event.contractAddress).toBe(''); + const payload = parseEventValue(event.value); + expect(payload.contractAddress).toBeNull(); + }); + + it('(b) contractAddress is propagated when provided', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const contractAddr = 'CABC123DEF456'; + const updated = store.update('user-7', { categories: { discord: true } }); + emitter.emitSubscriptionUpdated(updated, contractAddr); + + const event = registry.getEvents()[0]; + expect(event.contractAddress).toBe(contractAddr); + const payload = parseEventValue(event.value); + expect(payload.contractAddress).toBe(contractAddr); + }); + + it('(b) eventId is deterministic and encodes userId and updatedAt', () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const updated = store.update('user-8', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(updated); + + const eventId = registry.getEvents()[0].eventId; + expect(eventId).toBe( + `subscription_updated:user-8:${updated.updatedAt}` + ); + }); + + it('multiple updates emit one event per call, each with distinct updatedAt values', async () => { + const registry = makeRegistry(); + const emitter = makeEmitter(registry); + const store = makeStore(); + + const first = store.update('user-9', { categories: { discord: false } }); + emitter.emitSubscriptionUpdated(first); + + // Small delay to ensure a different updatedAt timestamp + await new Promise((r) => setTimeout(r, 5)); + + const second = store.update('user-9', { categories: { discord: true } }); + emitter.emitSubscriptionUpdated(second); + + const events = registry.getEvents(); + expect(events).toHaveLength(2); + expect(events[0].eventName).toBe('subscription_updated'); + expect(events[1].eventName).toBe('subscription_updated'); + + const p1 = parseEventValue(events[0].value); + const p2 = parseEventValue(events[1].value); + expect(p1.updatedAt).not.toBe(p2.updatedAt); + expect((p1.updatedCategories as Record).discord).toBe(false); + expect((p2.updatedCategories as Record).discord).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// (c) Negative — get() does NOT emit +// --------------------------------------------------------------------------- + +describe('SubscriptionEventEmitter — negative: get does not emit', () => { + it('(c) reading preferences with store.get() emits no event', () => { + const registry = makeRegistry(); + const store = makeStore(); + + // Just reading — emitter is never called + store.get('user-read-only'); + store.get('user-read-only'); + + // Nothing should be in the registry + expect(registry.getEvents()).toHaveLength(0); + }); + + it('(c) creating default preferences on first get() emits no event', () => { + const registry = makeRegistry(); + const store = makeStore(); + + // First-ever get creates a default record in the store + const prefs = store.get('brand-new-user'); + expect(prefs.categories.discord).toBe(true); // sanity: defaults are set + + // No event should have been emitted + expect(registry.getEvents()).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// (d) Negative — no emission without calling emitSubscriptionUpdated +// --------------------------------------------------------------------------- + +describe('SubscriptionEventEmitter — negative: no emission on create/delete context', () => { + it('(d) emitter.emitSubscriptionUpdated is never called for a creation-only flow', () => { + const registry = makeRegistry(); + const store = makeStore(); + + // Simulate a "subscription creation" path that only reads defaults + store.get('new-subscriber'); + + // The emitter is NOT invoked (no subscription_updated call) + // Registry must remain empty + expect(registry.getEvents()).toHaveLength(0); + expect( + registry.getEvents().some((e) => e.eventName === 'subscription_updated') + ).toBe(false); + }); + + it('(d) resetting a store (simulating delete) without calling emitter produces no event', () => { + const registry = makeRegistry(); + const store = makeStore(); + + // Update once so there is something to "delete" + store.update('user-del', { categories: { discord: true } }); + // Simulate deletion by creating a fresh store (the old one is discarded) + // — no emitter call, so no event should appear + const freshRegistry = makeRegistry(); + expect(freshRegistry.getEvents()).toHaveLength(0); + }); +}); diff --git a/listener/src/services/subscription-event-emitter.ts b/listener/src/services/subscription-event-emitter.ts new file mode 100644 index 0000000..b29aee3 --- /dev/null +++ b/listener/src/services/subscription-event-emitter.ts @@ -0,0 +1,87 @@ +/** + * SubscriptionEventEmitter — Issue #372 + * + * Emits a `subscription_updated` event into the EventRegistry whenever a + * user's notification preferences (their "subscription") are updated. + * + * Design notes: + * - The EventRegistry is injected rather than imported directly so tests can + * supply an isolated instance and verify emitted events without side-effects + * on the global singleton. + * - Event fields match the DisplayEvent / RegistryEventInput shape exactly: + * eventId — deterministic: "subscription_updated::" + * contractAddress — empty string; subscriptions are off-chain preference + * records with no on-chain contract address + * eventName — "subscription_updated" (snake_case, matches naming + * convention of notification_scheduled / notification_expired) + * ledger — 0; no on-chain ledger for preference updates + * type — "subscription" + * topic — ["subscription_updated", userId] + * value — JSON-encoded summary of what changed (updatedCategories) + * txHash — undefined; no transaction + * receivedAt — set by EventRegistry.addFromInput() via Date.now() + */ + +import * as StellarSDK from '@stellar/stellar-sdk'; +import { EventRegistry } from '../store/event-registry'; +import { UserPreferences } from '../types/preferences'; + +export interface SubscriptionUpdatedPayload { + /** The user ID whose preferences were updated. */ + subscriptionId: string; + /** Epoch-ms timestamp at which the update was applied. */ + updatedAt: number; + /** + * The category map as it stands after the update. + * Capturing the full updated state (rather than just the diff) lets consumers + * reconstruct current state without replaying history. + */ + updatedCategories: Record; + /** + * Contract address associated with the subscription, if any. + * Preference updates are off-chain, so this is null unless explicitly supplied. + */ + contractAddress: string | null; +} + +export class SubscriptionEventEmitter { + constructor(private readonly registry: EventRegistry) {} + + /** + * Emit a `subscription_updated` event for the given preference update result. + * + * Call this immediately after `PreferenceStore.update()` returns so the event + * carries the post-update state. + * + * @param updated The UserPreferences object returned by PreferenceStore.update() + * @param contractAddress Optional contract address to associate with the event. + */ + emitSubscriptionUpdated( + updated: UserPreferences, + contractAddress: string | null = null + ): void { + const payload: SubscriptionUpdatedPayload = { + subscriptionId: updated.userId, + updatedAt: updated.updatedAt, + updatedCategories: { ...updated.categories }, + contractAddress, + }; + + const eventId = `subscription_updated:${updated.userId}:${updated.updatedAt}`; + const valueJson = JSON.stringify(payload); + + this.registry.addFromInput({ + eventId, + contractAddress: contractAddress ?? '', + eventName: 'subscription_updated', + ledger: 0, + type: 'subscription', + // topic: symbol ScVals matching the eventName and userId so matchesEventFilter works + topic: [ + StellarSDK.xdr.ScVal.scvSymbol('subscription_updated'), + StellarSDK.xdr.ScVal.scvSymbol(updated.userId), + ], + value: StellarSDK.xdr.ScVal.scvString(valueJson), + }); + } +}