diff --git a/packages/bitbadgesjs-sdk/bun.lockb b/packages/bitbadgesjs-sdk/bun.lockb index c7168fb7dd..a4f839ff4c 100755 Binary files a/packages/bitbadgesjs-sdk/bun.lockb and b/packages/bitbadgesjs-sdk/bun.lockb differ diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/BitBadgesApi.ts b/packages/bitbadgesjs-sdk/src/api-indexer/BitBadgesApi.ts index a388331076..b56783a2e6 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/BitBadgesApi.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/BitBadgesApi.ts @@ -112,6 +112,10 @@ import { SignOutSuccessResponse, SimulateClaimSuccessResponse, SimulateTxSuccessResponse, + GetNotificationsSuccessResponse, + GetUnreadNotificationCountSuccessResponse, + MarkNotificationsReadSuccessResponse, + UpdateNotificationPreferencesSuccessResponse, UpdateAccountInfoSuccessResponse, UpdateClaimSuccessResponse, UpdateDeveloperAppSuccessResponse, @@ -310,6 +314,14 @@ import { iSimulateClaimSuccessResponse, iSimulateTxPayload, iSimulateTxSuccessResponse, + iGetNotificationsPayload, + iGetNotificationsSuccessResponse, + iGetUnreadNotificationCountPayload, + iGetUnreadNotificationCountSuccessResponse, + iMarkNotificationsReadPayload, + iMarkNotificationsReadSuccessResponse, + iUpdateNotificationPreferencesPayload, + iUpdateNotificationPreferencesSuccessResponse, iUpdateAccountInfoPayload, iUpdateAccountInfoSuccessResponse, iUpdateClaimPayload, @@ -3428,6 +3440,106 @@ export class BitBadgesAdminAPI extends BitBadgesAPI { } } + /** + * Gets the signed-in user's in-app notifications (inbox), newest first. + * + * **API Route**: `GET /api/v0/notifications` + * + * **Authentication**: Must be signed in. + */ + public async getNotifications(payload?: iGetNotificationsPayload): Promise> { + try { + const validateRes: typia.IValidation = typia.validate(payload ?? {}); + if (!validateRes.success) { + throw new Error('Invalid payload: ' + JSON.stringify(validateRes.errors)); + } + + const response = await this.axios.get>( + `${this.BACKEND_URL}${BitBadgesApiRoutes.GetNotificationsRoute()}`, + { params: payload } + ); + return new GetNotificationsSuccessResponse(response.data).convert(this.ConvertFunction); + } catch (error) { + await this.handleApiError(error); + return Promise.reject(error); + } + } + + /** + * Gets the signed-in user's unread notification count. + * + * **API Route**: `GET /api/v0/notifications/unreadCount` + * + * **Authentication**: Must be signed in. + */ + public async getUnreadNotificationCount( + payload?: iGetUnreadNotificationCountPayload + ): Promise { + try { + const response = await this.axios.get( + `${this.BACKEND_URL}${BitBadgesApiRoutes.GetUnreadNotificationCountRoute()}`, + { params: payload ?? {} } + ); + return new GetUnreadNotificationCountSuccessResponse(response.data); + } catch (error) { + await this.handleApiError(error); + return Promise.reject(error); + } + } + + /** + * Marks notifications as read/unread for the signed-in user. + * + * **API Route**: `POST /api/v0/notifications/read` + * + * **Authentication**: Must be signed in. + */ + public async markNotificationsRead(payload: iMarkNotificationsReadPayload): Promise { + try { + const validateRes: typia.IValidation = typia.validate(payload ?? {}); + if (!validateRes.success) { + throw new Error('Invalid payload: ' + JSON.stringify(validateRes.errors)); + } + + const response = await this.axios.post( + `${this.BACKEND_URL}${BitBadgesApiRoutes.MarkNotificationsReadRoute()}`, + payload + ); + return new MarkNotificationsReadSuccessResponse(response.data); + } catch (error) { + await this.handleApiError(error); + return Promise.reject(error); + } + } + + /** + * Updates the signed-in user's in-app notification preferences. + * + * **API Route**: `POST /api/v0/notifications/preferences` + * + * **Authentication**: Must be signed in. + */ + public async updateNotificationPreferences( + payload: iUpdateNotificationPreferencesPayload + ): Promise { + try { + const validateRes: typia.IValidation = + typia.validate(payload ?? ({} as iUpdateNotificationPreferencesPayload)); + if (!validateRes.success) { + throw new Error('Invalid payload: ' + JSON.stringify(validateRes.errors)); + } + + const response = await this.axios.post( + `${this.BACKEND_URL}${BitBadgesApiRoutes.UpdateNotificationPreferencesRoute()}`, + payload + ); + return new UpdateNotificationPreferencesSuccessResponse(response.data); + } catch (error) { + await this.handleApiError(error); + return Promise.reject(error); + } + } + /** * A generic route for verifying SIWBB requests. Used as a helper if implementing on your own. * diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/docs.ts b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/docs.ts index 270b9a5801..a0f5343090 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/docs.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/docs.ts @@ -4,6 +4,7 @@ import { BaseNumberTypeClass, CustomTypeClass, convertClassPropertiesAndMaintain import type { NumberType } from '@/common/string-numbers.js'; import type { SupportedChain } from '@/common/types.js'; import { AddressList } from '@/core/addressLists.js'; +import { TransferActivityDoc } from '@/api-indexer/docs-types/activity.js'; import { ApprovalInfoDetails, ChallengeDetails, @@ -79,6 +80,10 @@ import { type iEmailVerificationStatus, type iFetchDoc, type iIPFSTotalsDoc, + type iNotificationDoc, + type iNotificationPayload, + type iTransferActivityDoc, + type NotificationType, type iLatestBlockStatus, type iMerkleChallengeTrackerDoc, type iNotificationPreferences, @@ -472,6 +477,9 @@ export class NotificationPreferences claimActivity?: boolean; ignoreIfInitiator?: boolean; signInAlertsEnabled?: boolean; + inAppEnabled?: boolean; + inAppTransferActivity?: boolean; + inAppClaimActivity?: boolean; }; constructor(data: iNotificationPreferences) { @@ -487,6 +495,53 @@ export class NotificationPreferences } } +/** + * @inheritDoc iNotificationDoc + * @category Indexer + */ +export class NotificationDoc extends BaseNumberTypeClass> implements iNotificationDoc { + _docId: string; + _id?: string; + bitbadgesAddress: BitBadgesAddress; + type: NotificationType; + read: boolean; + createdAt: T; + title: string; + message?: string; + link?: string; + collectionId?: CollectionId; + address?: BitBadgesAddress; + payload?: iNotificationPayload; + + constructor(data: iNotificationDoc) { + super(); + this._docId = data._docId; + this._id = data._id; + this.bitbadgesAddress = data.bitbadgesAddress; + this.type = data.type; + this.read = data.read; + this.createdAt = data.createdAt; + this.title = data.title; + this.message = data.message; + this.link = data.link; + this.collectionId = data.collectionId; + this.address = data.address; + // Rebuild the embedded activity as a real class instance so convert() recurses into + // its number fields; the rest of the payload is display-ready and passes through as-is. + this.payload = data.payload + ? { ...data.payload, activity: data.payload.activity ? new TransferActivityDoc(data.payload.activity) : undefined } + : undefined; + } + + getNumberFieldNames(): string[] { + return ['createdAt']; + } + + convert(convertFunction: (item: NumberType) => U, options?: ConvertOptions): NotificationDoc { + return convertClassPropertiesAndMaintainNumberTypes(this, convertFunction, options) as NotificationDoc; + } +} + /** * @inheritDoc iEmailVerificationStatus * @category Accounts diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/index.ts b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/index.ts index abf58b5d7c..c042a165fe 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/index.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/index.ts @@ -1,3 +1,4 @@ export * from './activity.js'; export * from './docs.js'; export * from './interfaces.js'; +export * from './websockets.js'; diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/interfaces.ts b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/interfaces.ts index e430f8d7e4..b0021129d7 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/interfaces.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/interfaces.ts @@ -99,9 +99,109 @@ export interface iNotificationPreferences { claimActivity?: boolean; ignoreIfInitiator?: boolean; signInAlertsEnabled?: boolean; + /** Master toggle for the in-app notification inbox. Defaults to enabled when undefined. */ + inAppEnabled?: boolean; + /** In-app inbox: receive transfer notifications. Defaults to enabled when undefined. */ + inAppTransferActivity?: boolean; + /** In-app inbox: receive claim notifications. Defaults to enabled when undefined. */ + inAppClaimActivity?: boolean; }; } +/** + * The in-app notification categories (inbox event types). This is the discriminator + * for {@link iNotificationDoc} — every type shares the same presentation envelope and + * carries its own structured detail in {@link iNotificationPayload}. New event types + * (e.g. swaps, votes) are added here + given a `payload` shape + a frontend renderer. + * + * @category Interfaces + */ +export type NotificationType = 'transfer' | 'bank_send' | 'claim' | 'points' | 'list' | 'system' | 'intent_satisfied'; + +/** + * A coin (denom) amount, as display-ready strings (no number-type conversion needed). + * + * @category Interfaces + */ +export interface iNotificationCoin { + amount: string; + denom: string; +} + +/** + * Type-specific detail for a notification's drilldown view. The populated key is + * determined by {@link iNotificationDoc.type}; renderers narrow on `type`. This is the + * extension point: a new notification type adds its structured detail here. + * + * @category Interfaces + */ +export interface iNotificationPayload { + /** `transfer`: the source on-chain token transfer activity (embedded for rich rendering). */ + activity?: iTransferActivityDoc; + /** `bank_send`: a native coin (denom) transfer. */ + bankSend?: { + fromAddress: BitBadgesAddress; + toAddress: BitBadgesAddress; + amount: iNotificationCoin[]; + txHash?: string; + }; + /** `intent_satisfied`: a standing intent (approval) was filled — multiple on-chain legs collapsed into one event. */ + intentSatisfied?: { + /** The intent approval that was consumed. */ + approvalId: string; + /** Who set up the intent (the maker / "my intent was satisfied" recipient). */ + approverAddress: BitBadgesAddress; + /** Who filled the intent (the taker / initiator). */ + filler: BitBadgesAddress; + collectionId: CollectionId; + txHash?: string; + /** Coins that moved as part of the fill (payment/payout), display-ready. */ + coins: iNotificationCoin[]; + /** Compact summary of token IDs involved, e.g. "1-3, 7". */ + tokenIds?: string; + /** How many on-chain transfer legs were collapsed into this single notification. */ + legCount: number; + }; + /** Misc display-ready scalars for any type (tokenIds, claimId, points, …). */ + extra?: Record; +} + +/** + * An in-app notification (inbox entry). One document per recipient per event. + * + * Shape = a **standardized envelope** (rendered identically for every type in the inbox + * list) + a **type-specific `payload`** surfaced only in the drilldown. `type` is the + * discriminator (see {@link NotificationType}). + * + * @category Interfaces + */ +export interface iNotificationDoc extends Doc { + /** The recipient of this notification. */ + bitbadgesAddress: BitBadgesAddress; + /** The category of the notification — the discriminator for `payload` + rendering. */ + type: NotificationType; + /** Whether the recipient has read this notification. */ + read: boolean; + /** When the notification was created (UNIX ms). */ + createdAt: UNIXMilliTimestamp; + + // --- Standardized presentation envelope (consistent across all types) --- + /** Headline for the inbox row. */ + title: string; + /** Optional one-line body for the inbox row. */ + message?: string; + /** Optional in-app link to navigate to (e.g. "view source"). */ + link?: string; + /** Subject for the row's leading avatar: a collection (token avatar). */ + collectionId?: CollectionId; + /** Subject for the row's leading avatar: an address (user avatar / counterparty). */ + address?: BitBadgesAddress; + + // --- Type-specific drilldown detail (shape determined by `type`) --- + /** Structured detail surfaced when the row is expanded/drilled into. */ + payload?: iNotificationPayload; +} + /** * The verification status of the user's email. * @@ -618,7 +718,6 @@ export interface iProfileDoc extends Doc { /** The notifications of the account */ notifications?: iNotificationPreferences; - } /** @@ -793,14 +892,7 @@ export interface iBalanceDocWithDetails extends iBalanceDo /** * @category Claims */ -export type ClaimIntegrationPluginType = - | 'codes' - | 'password' - | 'numUses' - | 'transferTimes' - | 'initiatedBy' - | 'whitelist' - | string; +export type ClaimIntegrationPluginType = 'codes' | 'password' | 'numUses' | 'transferTimes' | 'initiatedBy' | 'whitelist' | string; /** * @category Claims @@ -1678,16 +1770,12 @@ export type DynamicDataHandlerType = 'addresses'; /** * @category Interfaces */ -export type DynamicDataHandlerData = Q extends 'addresses' - ? { addresses: string[] } - : never; +export type DynamicDataHandlerData = Q extends 'addresses' ? { addresses: string[] } : never; /** * @category Interfaces */ -export type DynamicDataHandlerActionPayload = Q extends 'addresses' - ? { address: string } - : never; +export type DynamicDataHandlerActionPayload = Q extends 'addresses' ? { address: string } : never; /** * @category Interfaces diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/websockets.ts b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/websockets.ts new file mode 100644 index 0000000000..d621a4c997 --- /dev/null +++ b/packages/bitbadgesjs-sdk/src/api-indexer/docs-types/websockets.ts @@ -0,0 +1,31 @@ +import type { NumberType } from '@/common/string-numbers.js'; +import type { iNotificationDoc } from './interfaces.js'; + +/** + * Messages pushed from the indexer websocket to a signed-in client on their + * per-user notification channel. The channel is bound to the authenticated + * session on the websocket upgrade — clients never specify which address's + * notifications to receive, so these messages are always for the connected user. + * + * @category Websockets + */ +export type NotificationWsServerMessage = + | { + type: 'notification'; + /** A newly created in-app notification for the connected user. */ + notification: iNotificationDoc; + } + | { + type: 'unread_count'; + /** The connected user's current unread count. */ + count: number; + }; + +/** + * Control messages a client may send on the notification websocket. + * Subscription is implicit (derived from the authenticated session), so the + * only client message is a heartbeat keepalive. + * + * @category Websockets + */ +export type NotificationWsClientMessage = { type: 'heartbeat' }; diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/requests/requests.ts b/packages/bitbadgesjs-sdk/src/api-indexer/requests/requests.ts index a99f9dea1b..80ff19655a 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/requests/requests.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/requests/requests.ts @@ -16,6 +16,7 @@ import { DynamicDataDoc, DynamicStoreDocWithDetails, DynamicStoreValueDoc, + NotificationDoc, PluginDoc, SIWBBRequestDoc, StatusDoc, @@ -38,6 +39,7 @@ import { iEstimatedCost, iInheritMetadataFrom, iLinkedTo, + iNotificationDoc, iPointsActivityDoc, iSIWBBRequestDoc, iUtilityPageContent, @@ -49,6 +51,7 @@ import { type JsonBodyInputSchema, type JsonBodyInputWithValue, type NativeAddress, + type NotificationType, type OAuthScopeDetails, type PluginPresetType, type SiwbbMessage, @@ -916,6 +919,148 @@ export class UpdateAccountInfoSuccessResponse extends CustomTypeClass { + /** The notifications for the signed-in user, newest first. */ + notifications: iNotificationDoc[]; + /** Pagination info for fetching the next page. */ + pagination: PaginationInfo; +} + +/** + * @inheritDoc iGetNotificationsSuccessResponse + * @category API Requests / Responses + */ +export class GetNotificationsSuccessResponse + extends BaseNumberTypeClass> + implements iGetNotificationsSuccessResponse +{ + notifications: NotificationDoc[]; + pagination: PaginationInfo; + + constructor(data: iGetNotificationsSuccessResponse) { + super(); + this.notifications = data.notifications.map((doc) => new NotificationDoc(doc)); + this.pagination = data.pagination; + } + + convert(convertFunction: (item: NumberType) => U, options?: ConvertOptions): GetNotificationsSuccessResponse { + return convertClassPropertiesAndMaintainNumberTypes(this, convertFunction, options) as GetNotificationsSuccessResponse; + } +} + +/** + * @category API Requests / Responses + */ +export interface iGetUnreadNotificationCountPayload {} + +/** + * @category API Requests / Responses + */ +export interface iGetUnreadNotificationCountSuccessResponse { + /** The number of unread notifications for the signed-in user. */ + count: number; +} + +/** + * @category API Requests / Responses + */ +export class GetUnreadNotificationCountSuccessResponse + extends CustomTypeClass + implements iGetUnreadNotificationCountSuccessResponse +{ + count: number; + + constructor(data: iGetUnreadNotificationCountSuccessResponse) { + super(); + this.count = data.count; + } +} + +/** + * @category API Requests / Responses + */ +export interface iMarkNotificationsReadPayload { + /** Specific notification ids to mark read. Ignored if `all` is true. */ + notificationIds?: string[]; + /** Mark every notification read. */ + all?: boolean; + /** Mark as read (default true) or unread (false). */ + read?: boolean; +} + +/** + * @category API Requests / Responses + */ +export interface iMarkNotificationsReadSuccessResponse { + /** The number of notifications updated. */ + updated: number; +} + +/** + * @category API Requests / Responses + */ +export class MarkNotificationsReadSuccessResponse + extends CustomTypeClass + implements iMarkNotificationsReadSuccessResponse +{ + updated: number; + + constructor(data: iMarkNotificationsReadSuccessResponse) { + super(); + this.updated = data.updated; + } +} + +/** + * @category API Requests / Responses + */ +export interface iUpdateNotificationPreferencesPayload { + /** The in-app notification preferences to set. Only provided keys are updated. */ + preferences: { + inAppEnabled?: boolean; + inAppTransferActivity?: boolean; + inAppClaimActivity?: boolean; + ignoreIfInitiator?: boolean; + }; +} + +/** + * @category API Requests / Responses + */ +export interface iUpdateNotificationPreferencesSuccessResponse { + success: boolean; +} + +/** + * @category API Requests / Responses + */ +export class UpdateNotificationPreferencesSuccessResponse + extends CustomTypeClass + implements iUpdateNotificationPreferencesSuccessResponse +{ + success: boolean; + + constructor(data: iUpdateNotificationPreferencesSuccessResponse) { + super(); + this.success = data.success; + } +} + /** * @category API Requests / Responses */ diff --git a/packages/bitbadgesjs-sdk/src/api-indexer/requests/routes.ts b/packages/bitbadgesjs-sdk/src/api-indexer/requests/routes.ts index fd13cbafec..7e3957c3a6 100644 --- a/packages/bitbadgesjs-sdk/src/api-indexer/requests/routes.ts +++ b/packages/bitbadgesjs-sdk/src/api-indexer/requests/routes.ts @@ -50,6 +50,11 @@ export class BitBadgesApiRoutes { static GetAccountRoute = () => '/api/v0/user'; static UpdateAccountInfoRoute = () => '/api/v0/user/updateAccount'; + static GetNotificationsRoute = () => '/api/v0/notifications'; + static GetUnreadNotificationCountRoute = () => '/api/v0/notifications/unreadCount'; + static MarkNotificationsReadRoute = () => '/api/v0/notifications/read'; + static UpdateNotificationPreferencesRoute = () => '/api/v0/notifications/preferences'; + static GetApiKeysRoute = () => '/api/v0/apiKeys/fetch'; static CRUDApiKeysRoute = () => '/api/v0/apiKeys'; static RotateApiKeyRoute = () => '/api/v0/apiKeys/rotate';