diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 20fab3f618..ed23e006ea 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -31,6 +31,7 @@ interface Account id: ID! level: AccountLevel! limits: AccountLimits! + notificationSettings: NotificationSettings! realtimePrice: RealtimePrice! transactions( """Returns the items in the list that come after the specified cursor.""" @@ -56,6 +57,32 @@ type AccountDeletePayload success: Boolean! } +input AccountDisableNotificationCategoryInput + @join__type(graph: PUBLIC) +{ + category: NotificationCategory! + channel: NotificationChannel +} + +input AccountDisableNotificationChannelInput + @join__type(graph: PUBLIC) +{ + channel: NotificationChannel! +} + +input AccountEnableNotificationCategoryInput + @join__type(graph: PUBLIC) +{ + category: NotificationCategory! + channel: NotificationChannel +} + +input AccountEnableNotificationChannelInput + @join__type(graph: PUBLIC) +{ + channel: NotificationChannel! +} + enum AccountLevel @join__type(graph: PUBLIC) { @@ -120,6 +147,13 @@ type AccountUpdateDisplayCurrencyPayload errors: [Error!]! } +type AccountUpdateNotificationSettingsPayload + @join__type(graph: PUBLIC) +{ + account: ConsumerAccount + errors: [Error!]! +} + """An Opaque Bearer token""" scalar AuthToken @join__type(graph: PUBLIC) @@ -267,6 +301,7 @@ type ConsumerAccount implements Account id: ID! level: AccountLevel! limits: AccountLimits! + notificationSettings: NotificationSettings! """List the quiz questions of the consumer account""" quiz: [Quiz!]! @@ -816,6 +851,10 @@ type Mutation @join__type(graph: PUBLIC) { accountDelete: AccountDeletePayload! + accountDisableNotificationCategory(input: AccountDisableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! + accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! + accountEnableNotificationCategory(input: AccountEnableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! + accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! @@ -949,6 +988,28 @@ enum Network testnet @join__enumValue(graph: PUBLIC) } +scalar NotificationCategory + @join__type(graph: PUBLIC) + +enum NotificationChannel + @join__type(graph: PUBLIC) +{ + PUSH @join__enumValue(graph: PUBLIC) +} + +type NotificationChannelSettings + @join__type(graph: PUBLIC) +{ + disabledCategories: [NotificationCategory!]! + enabled: Boolean! +} + +type NotificationSettings + @join__type(graph: PUBLIC) +{ + push: NotificationChannelSettings! +} + """An address for an on-chain bitcoin destination""" scalar OnChainAddress @join__type(graph: PUBLIC) diff --git a/src/app/accounts/disable-notification-category.ts b/src/app/accounts/disable-notification-category.ts new file mode 100644 index 0000000000..8bc1bc4014 --- /dev/null +++ b/src/app/accounts/disable-notification-category.ts @@ -0,0 +1,31 @@ +import { + checkedToNotificationCategory, + disableNotificationCategory as domainDisableNotificationCategory, +} from "@domain/notifications" +import { AccountsRepository } from "@services/mongoose" + +export const disableNotificationCategory = async ({ + accountId, + notificationChannel, + notificationCategory, +}: { + accountId: AccountId + notificationChannel?: NotificationChannel + notificationCategory: string +}): Promise => { + const checkedNotificationCategory = checkedToNotificationCategory(notificationCategory) + if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const newNotificationSettings = domainDisableNotificationCategory({ + notificationSettings: account.notificationSettings, + notificationChannel, + notificationCategory: checkedNotificationCategory, + }) + + account.notificationSettings = newNotificationSettings + + return AccountsRepository().update(account) +} diff --git a/src/app/accounts/disable-notification-channel.ts b/src/app/accounts/disable-notification-channel.ts new file mode 100644 index 0000000000..1e5eed47eb --- /dev/null +++ b/src/app/accounts/disable-notification-channel.ts @@ -0,0 +1,22 @@ +import { disableNotificationChannel as domainDisableNotificationChannel } from "@domain/notifications" +import { AccountsRepository } from "@services/mongoose" + +export const disableNotificationChannel = async ({ + accountId, + notificationChannel, +}: { + accountId: AccountId + notificationChannel: NotificationChannel +}): Promise => { + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const newNotificationSettings = domainDisableNotificationChannel({ + notificationSettings: account.notificationSettings, + notificationChannel, + }) + + account.notificationSettings = newNotificationSettings + + return AccountsRepository().update(account) +} diff --git a/src/app/accounts/enable-notification-category.ts b/src/app/accounts/enable-notification-category.ts new file mode 100644 index 0000000000..6248e6a499 --- /dev/null +++ b/src/app/accounts/enable-notification-category.ts @@ -0,0 +1,31 @@ +import { + checkedToNotificationCategory, + enableNotificationCategory as domainEnableNotificationCategory, +} from "@domain/notifications" +import { AccountsRepository } from "@services/mongoose" + +export const enableNotificationCategory = async ({ + accountId, + notificationChannel, + notificationCategory, +}: { + accountId: AccountId + notificationChannel?: NotificationChannel + notificationCategory: string +}): Promise => { + const checkedNotificationCategory = checkedToNotificationCategory(notificationCategory) + if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const newNotificationSettings = domainEnableNotificationCategory({ + notificationSettings: account.notificationSettings, + notificationChannel, + notificationCategory: checkedNotificationCategory, + }) + + account.notificationSettings = newNotificationSettings + + return AccountsRepository().update(account) +} diff --git a/src/app/accounts/enable-notification-channel.ts b/src/app/accounts/enable-notification-channel.ts new file mode 100644 index 0000000000..4843786bf7 --- /dev/null +++ b/src/app/accounts/enable-notification-channel.ts @@ -0,0 +1,22 @@ +import { enableNotificationChannel as domainEnableNotificationChannel } from "@domain/notifications" +import { AccountsRepository } from "@services/mongoose" + +export const enableNotificationChannel = async ({ + accountId, + notificationChannel, +}: { + accountId: AccountId + notificationChannel: NotificationChannel +}): Promise => { + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const newNotificationSettings = domainEnableNotificationChannel({ + notificationSettings: account.notificationSettings, + notificationChannel, + }) + + account.notificationSettings = newNotificationSettings + + return AccountsRepository().update(account) +} diff --git a/src/app/accounts/index.ts b/src/app/accounts/index.ts index 285cdb3953..1daba7632d 100644 --- a/src/app/accounts/index.ts +++ b/src/app/accounts/index.ts @@ -21,6 +21,10 @@ export * from "./update-display-currency" export * from "./username-available" export * from "./delete-business-map-info" export * from "./upgrade-device-account" +export * from "./disable-notification-category" +export * from "./enable-notification-category" +export * from "./enable-notification-channel" +export * from "./disable-notification-channel" const accounts = AccountsRepository() diff --git a/src/app/accounts/send-default-wallet-balance-to-users.ts b/src/app/accounts/send-default-wallet-balance-to-users.ts index 475289f150..9a74f279ac 100644 --- a/src/app/accounts/send-default-wallet-balance-to-users.ts +++ b/src/app/accounts/send-default-wallet-balance-to-users.ts @@ -64,6 +64,7 @@ export const sendDefaultWalletBalanceToAccounts = async () => { deviceTokens: user.deviceTokens, displayBalanceAmount: displayAmount, recipientLanguage: user.language, + notificationSettings: account.notificationSettings, }) if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { diff --git a/src/app/admin/send-admin-push-notification.ts b/src/app/admin/send-admin-push-notification.ts index 01ee7d511e..5694214423 100644 --- a/src/app/admin/send-admin-push-notification.ts +++ b/src/app/admin/send-admin-push-notification.ts @@ -1,4 +1,8 @@ import { checkedToAccountUuid } from "@domain/accounts" +import { + GaloyNotificationCategories, + checkedToNotificationCategory, +} from "@domain/notifications" import { AccountsRepository } from "@services/mongoose/accounts" import { UsersRepository } from "@services/mongoose/users" import { NotificationsService } from "@services/notifications" @@ -8,12 +12,20 @@ export const sendAdminPushNotification = async ({ title, body, data, + notificationCategory, }: { accountId: string title: string body: string data?: { [key: string]: string } + notificationCategory?: string }): Promise => { + const checkedNotificationCategory = notificationCategory + ? checkedToNotificationCategory(notificationCategory) + : GaloyNotificationCategories.AdminPushNotification + + if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory + const accountId = checkedToAccountUuid(accountIdRaw) if (accountId instanceof Error) return accountId @@ -26,14 +38,14 @@ export const sendAdminPushNotification = async ({ const user = await usersRepo.findById(kratosUserId) if (user instanceof Error) return user - const success = await NotificationsService().adminPushNotificationSend({ + const success = await NotificationsService().adminPushNotificationFilteredSend({ deviceTokens: user.deviceTokens, title, body, data, + notificationCategory: checkedNotificationCategory, + notificationSettings: account.notificationSettings, }) - if (success instanceof Error) return success - return success } diff --git a/src/app/payments/send-intraledger.ts b/src/app/payments/send-intraledger.ts index fb11af5c4a..c78ff87009 100644 --- a/src/app/payments/send-intraledger.ts +++ b/src/app/payments/send-intraledger.ts @@ -365,6 +365,7 @@ const executePaymentViaIntraledger = async < recipientDeviceTokens: recipientUser.deviceTokens, recipientLanguage: recipientUser.language, paymentAmount: { amount, currency: recipientWallet.currency }, + recipientNotificationSettings: recipientAccount.notificationSettings, displayPaymentAmount: recipientDisplayAmount, }) diff --git a/src/app/payments/send-lightning.ts b/src/app/payments/send-lightning.ts index 04ecb8a9b9..9369d04f2f 100644 --- a/src/app/payments/send-lightning.ts +++ b/src/app/payments/send-lightning.ts @@ -531,6 +531,7 @@ const executePaymentViaIntraledger = async < displayPaymentAmount: recipientDisplayAmount, paymentHash, recipientDeviceTokens: recipientUser.deviceTokens, + recipientNotificationSettings: recipientAccount.notificationSettings, recipientLanguage: recipientUser.language, }) diff --git a/src/app/wallets/add-pending-on-chain-transaction.ts b/src/app/wallets/add-pending-on-chain-transaction.ts index fed58a42e1..53f5ed07cf 100644 --- a/src/app/wallets/add-pending-on-chain-transaction.ts +++ b/src/app/wallets/add-pending-on-chain-transaction.ts @@ -152,6 +152,7 @@ export const addPendingTransaction = async ({ displayPaymentAmount: settlementDisplayAmount, txHash: txId, recipientDeviceTokens: recipientUser.deviceTokens, + recipientNotificationSettings: account.notificationSettings, recipientLanguage: recipientUser.language, }) diff --git a/src/app/wallets/add-settled-on-chain-transaction.ts b/src/app/wallets/add-settled-on-chain-transaction.ts index 4070db638f..3c049f67a9 100644 --- a/src/app/wallets/add-settled-on-chain-transaction.ts +++ b/src/app/wallets/add-settled-on-chain-transaction.ts @@ -179,6 +179,7 @@ const addSettledTransactionBeforeFinally = async ({ displayPaymentAmount: displayAmount, txHash, recipientDeviceTokens: user.deviceTokens, + recipientNotificationSettings: account.notificationSettings, recipientLanguage: user.language, }) diff --git a/src/app/wallets/send-on-chain.ts b/src/app/wallets/send-on-chain.ts index d2505f5eb8..9ef46b62d1 100644 --- a/src/app/wallets/send-on-chain.ts +++ b/src/app/wallets/send-on-chain.ts @@ -430,6 +430,7 @@ const executePaymentViaIntraledger = async < paymentAmount: { amount, currency: recipientWalletCurrency }, displayPaymentAmount: recipientDisplayAmount, recipientDeviceTokens: recipientUser.deviceTokens, + recipientNotificationSettings: recipientAccount.notificationSettings, recipientLanguage: recipientUser.language, }) diff --git a/src/app/wallets/settle-payout-txn.ts b/src/app/wallets/settle-payout-txn.ts index 8140406662..2d5c7a6d65 100644 --- a/src/app/wallets/settle-payout-txn.ts +++ b/src/app/wallets/settle-payout-txn.ts @@ -59,6 +59,7 @@ export const settlePayout = async ( displayPaymentAmount, txHash, senderDeviceTokens: user.deviceTokens, + senderNotificationSettings: account.notificationSettings, senderLanguage: user.language, }) diff --git a/src/app/wallets/update-pending-invoices.ts b/src/app/wallets/update-pending-invoices.ts index 1984c5c601..5645bdc081 100644 --- a/src/app/wallets/update-pending-invoices.ts +++ b/src/app/wallets/update-pending-invoices.ts @@ -309,6 +309,7 @@ const updatePendingInvoiceBeforeFinally = async ({ displayPaymentAmount, paymentHash, recipientDeviceTokens: recipientUser.deviceTokens, + recipientNotificationSettings: recipientAccount.notificationSettings, recipientLanguage: recipientUser.language, }) diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 2c3baf4425..c7ea17b3c3 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -77,6 +77,7 @@ type Account = { readonly isEditor: boolean readonly quizQuestions: UserQuizQuestion[] // deprecated readonly quiz: Quiz[] + notificationSettings: NotificationSettings kratosUserId: UserId displayCurrency: DisplayCurrency // temp diff --git a/src/domain/notifications/errors.ts b/src/domain/notifications/errors.ts index 7003e56896..3dbce67a3a 100644 --- a/src/domain/notifications/errors.ts +++ b/src/domain/notifications/errors.ts @@ -17,3 +17,5 @@ export class NotificationsServiceUnreachableServerError extends NotificationsSer export class UnknownNotificationsServiceError extends NotificationsError { level = ErrorLevel.Critical } + +export class InvalidPushNotificationSettingError extends NotificationsError {} diff --git a/src/domain/notifications/index.ts b/src/domain/notifications/index.ts index 867b2ed1f6..5b199cb572 100644 --- a/src/domain/notifications/index.ts +++ b/src/domain/notifications/index.ts @@ -1,3 +1,5 @@ +import { InvalidPushNotificationSettingError as InvalidNotificationSettingsError } from "./errors" + export * from "./errors" export const NotificationType = { @@ -8,3 +10,165 @@ export const NotificationType = { OnchainPayment: "onchain_payment", LnInvoicePaid: "paid-invoice", } as const + +export const NotificationChannel = { + Push: "push", +} as const + +export const GaloyNotificationCategories = { + Payments: "Payments" as NotificationCategory, + Balance: "Balance" as NotificationCategory, + AdminPushNotification: "AdminPushNotification" as NotificationCategory, +} as const + +export const checkedToNotificationCategory = ( + notificationCategory: string, +): NotificationCategory | ValidationError => { + // TODO: add validation + if (!notificationCategory) { + return new InvalidNotificationSettingsError("Invalid notification category") + } + + return notificationCategory as NotificationCategory +} + +export const enableNotificationChannel = ({ + notificationSettings, + notificationChannel, +}: { + notificationSettings: NotificationSettings + notificationChannel: NotificationChannel +}): NotificationSettings => { + return setNotificationChannelIsEnabled({ + notificationSettings, + notificationChannel, + enabled: true, + }) +} + +export const disableNotificationChannel = ({ + notificationSettings, + notificationChannel, +}: { + notificationSettings: NotificationSettings + notificationChannel: NotificationChannel +}): NotificationSettings => { + return setNotificationChannelIsEnabled({ + notificationSettings, + notificationChannel, + enabled: false, + }) +} + +const setNotificationChannelIsEnabled = ({ + notificationSettings, + notificationChannel, + enabled, +}: { + notificationSettings: NotificationSettings + notificationChannel: NotificationChannel + enabled: boolean +}): NotificationSettings => { + const notificationChannelSettings = notificationSettings[notificationChannel] + const enabledChanged = notificationChannelSettings.enabled !== enabled + + const newNotificationSettings = { + enabled, + disabledCategories: enabledChanged + ? [] + : notificationChannelSettings.disabledCategories, + } + + return { + ...notificationSettings, + [notificationChannel]: newNotificationSettings, + } +} + +export const enableNotificationCategory = ({ + notificationSettings, + notificationChannel, + notificationCategory, +}: { + notificationSettings: NotificationSettings + notificationChannel?: NotificationChannel + notificationCategory: NotificationCategory +}): NotificationSettings => { + const notificationChannelsToUpdate: NotificationChannel[] = notificationChannel + ? [notificationChannel] + : Object.values(NotificationChannel) + + let newNotificationSettings = notificationSettings + + for (const notificationChannel of notificationChannelsToUpdate) { + const notificationChannelSettings = notificationSettings[notificationChannel] + const disabledCategories = notificationChannelSettings.disabledCategories + + const newNotificationChannelSettings = { + enabled: notificationChannelSettings.enabled, + disabledCategories: disabledCategories.filter( + (category) => category !== notificationCategory, + ), + } + + newNotificationSettings = { + ...notificationSettings, + [notificationChannel]: newNotificationChannelSettings, + } + } + + return newNotificationSettings +} + +export const disableNotificationCategory = ({ + notificationSettings, + notificationChannel, + notificationCategory, +}: { + notificationSettings: NotificationSettings + notificationChannel?: NotificationChannel + notificationCategory: NotificationCategory +}): NotificationSettings => { + const notificationChannelsToUpdate: NotificationChannel[] = notificationChannel + ? [notificationChannel] + : Object.values(NotificationChannel) + + let newNotificationSettings = notificationSettings + + for (const notificationChannel of notificationChannelsToUpdate) { + const notificationChannelSettings = notificationSettings[notificationChannel] + const disabledCategories = notificationChannelSettings.disabledCategories + disabledCategories.push(notificationCategory) + const uniqueDisabledCategories = [...new Set(disabledCategories)] + + const newNotificationChannelSettings = { + enabled: notificationChannelSettings.enabled, + disabledCategories: uniqueDisabledCategories, + } + + newNotificationSettings = { + ...notificationSettings, + [notificationChannel]: newNotificationChannelSettings, + } + } + + return newNotificationSettings +} + +export const shouldSendNotification = ({ + notificationChannel, + notificationSettings, + notificationCategory, +}: { + notificationChannel: NotificationChannel + notificationSettings: NotificationSettings + notificationCategory: NotificationCategory +}): boolean => { + const channelNotificationSettings = notificationSettings[notificationChannel] + + if (channelNotificationSettings.enabled) { + return !channelNotificationSettings.disabledCategories.includes(notificationCategory) + } + + return false +} diff --git a/src/domain/notifications/index.types.d.ts b/src/domain/notifications/index.types.d.ts index af94f7552e..d66a246354 100644 --- a/src/domain/notifications/index.types.d.ts +++ b/src/domain/notifications/index.types.d.ts @@ -13,6 +13,7 @@ type TransactionReceivedNotificationBaseArgs = TransactionNotificationBaseArgs & recipientAccountId: AccountId recipientWalletId: WalletId recipientDeviceTokens: DeviceToken[] + recipientNotificationSettings: NotificationSettings recipientLanguage: UserLanguageOrEmpty } @@ -20,6 +21,7 @@ type TransactionSentNotificationBaseArgs = TransactionNotificationBaseArgs & { senderAccountId: AccountId senderWalletId: WalletId senderDeviceTokens: DeviceToken[] + senderNotificationSettings: NotificationSettings senderLanguage: UserLanguageOrEmpty } @@ -41,6 +43,7 @@ type OnChainTxSentArgs = TransactionSentNotificationBaseArgs & OnChainTxBaseArgs type SendBalanceArgs = { balanceAmount: BalanceAmount deviceTokens: DeviceToken[] + notificationSettings: NotificationSettings displayBalanceAmount?: DisplayAmount recipientLanguage: UserLanguageOrEmpty } @@ -72,4 +75,17 @@ interface INotificationsService { adminPushNotificationSend( args: SendPushNotificationArgs, ): Promise + adminPushNotificationFilteredSend( + args: SendFilteredPushNotificationArgs, + ): Promise +} + +type NotificationChannel = + (typeof import("./index").NotificationChannel)[keyof typeof import("./index").NotificationChannel] + +type NotificationSettings = Record + +type NotificationChannelSettings = { + enabled: boolean + disabledCategories: NotificationCategory[] } diff --git a/src/domain/primitives/index.types.d.ts b/src/domain/primitives/index.types.d.ts index dc599ebed3..b6d59b19dc 100644 --- a/src/domain/primitives/index.types.d.ts +++ b/src/domain/primitives/index.types.d.ts @@ -11,6 +11,7 @@ type MilliSeconds = number & { readonly brand: unique symbol } type Days = number & { readonly brand: unique symbol } type JwtToken = string & { readonly brand: unique symbol } // short lived asymmetric token from oathkeeper type Memo = string & { readonly brand: unique symbol } +type NotificationCategory = string & { readonly brand: unique symbol } type XOR = | (T1 & { [k in Exclude]?: never }) diff --git a/src/graphql/admin/root/mutation/admin-push-notification-send.ts b/src/graphql/admin/root/mutation/admin-push-notification-send.ts index 2dceb47dbc..41123f5bdc 100644 --- a/src/graphql/admin/root/mutation/admin-push-notification-send.ts +++ b/src/graphql/admin/root/mutation/admin-push-notification-send.ts @@ -3,22 +3,26 @@ import { GT } from "@graphql/index" import AdminPushNotificationSendPayload from "@graphql/admin/types/payload/admin-push-notification-send" import { Admin } from "@app" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import NotificationCategory from "@graphql/shared/types/scalar/notification-category" const AdminPushNotificationSendInput = GT.Input({ name: "AdminPushNotificationSendInput", fields: () => ({ accountId: { - type: GT.String, + type: GT.NonNull(GT.String), }, title: { - type: GT.String, + type: GT.NonNull(GT.String), }, body: { - type: GT.String, + type: GT.NonNull(GT.String), }, data: { type: GT.Scalar(Object), }, + notificationCategory: { + type: NotificationCategory, + }, }), }) @@ -31,6 +35,7 @@ const AdminPushNotificationSendMutation = GT.Field< title: string body: string data?: { [key: string]: string } + notificationCategory?: string } } >({ @@ -42,13 +47,14 @@ const AdminPushNotificationSendMutation = GT.Field< input: { type: GT.NonNull(AdminPushNotificationSendInput) }, }, resolve: async (_, args) => { - const { accountId, body, title, data } = args.input + const { accountId, body, title, data, notificationCategory } = args.input const success = await Admin.sendAdminPushNotification({ accountId, title, body, data, + notificationCategory, }) if (success instanceof Error) { diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 0d50f3750f..0998abf30a 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -29,10 +29,11 @@ input AccountUpdateStatusInput { } input AdminPushNotificationSendInput { - accountId: String - body: String + accountId: String! + body: String! data: Object - title: String + notificationCategory: NotificationCategory + title: String! } type AdminPushNotificationSendPayload { @@ -252,6 +253,8 @@ type Mutation { userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload! } +scalar NotificationCategory + scalar Object """An address for an on-chain bitcoin destination""" diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 2585128188..71ff54ce11 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -256,6 +256,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Invalid quiz question id was passed." return new ValidationInternalError({ message, logger: baseLogger }) + case "InvalidPushNotificationSettingError": + message = "Invalid push notification setting was passed." + return new ValidationInternalError({ message, logger: baseLogger }) + case "RewardAlreadyPresentError": message = "Reward for quiz question was already claimed." return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index f3655b8e4c..892aabc502 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -49,6 +49,10 @@ import UserTotpDeleteMutation from "@graphql/public/root/mutation/user-totp-dele import CallbackEndpointAdd from "./root/mutation/callback-endpoint-add" import CallbackEndpointDelete from "./root/mutation/callback-endpoint-delete" +import AccountEnableNotificationCategoryMutation from "./root/mutation/account-enable-notification-category-for-channel" +import AccountDisableNotificationCategoryMutation from "./root/mutation/account-disable-notification-category-for-channel" +import AccountEnableNotificationChannelMutation from "./root/mutation/account-enable-notification-channel" +import AccountDisableNotificationChannelMutation from "./root/mutation/account-disable-notification-channel" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -86,6 +90,11 @@ export const mutationFields = { userContactUpdateAlias: UserContactUpdateAliasMutation, accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdMutation, accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyMutation, + accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation, + accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation, + accountEnableNotificationChannel: AccountEnableNotificationChannelMutation, + accountDisableNotificationChannel: AccountDisableNotificationChannelMutation, + accountDelete: AccountDeleteMutation, feedbackSubmit: FeedbackSubmitMutation, diff --git a/src/graphql/public/root/mutation/account-disable-notification-category-for-channel.ts b/src/graphql/public/root/mutation/account-disable-notification-category-for-channel.ts new file mode 100644 index 0000000000..0a551e35bd --- /dev/null +++ b/src/graphql/public/root/mutation/account-disable-notification-category-for-channel.ts @@ -0,0 +1,60 @@ +import { GT } from "@graphql/index" + +import AccountUpdateNotificationSettingsPayload from "@graphql/public/types/payload/account-update-notification-settings" +import { Accounts } from "@app/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import NotificationChannel from "@graphql/shared/types/scalar/notification-channel" +import NotificationCategory from "@graphql/shared/types/scalar/notification-category" + +const AccountDisableNotificationCategoryInput = GT.Input({ + name: "AccountDisableNotificationCategoryInput", + fields: () => ({ + channel: { + type: NotificationChannel, + }, + category: { + type: GT.NonNull(NotificationCategory), + }, + }), +}) + +const AccountDisableNotificationCategoryMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + channel?: NotificationChannel | Error + category: NotificationCategory + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(AccountUpdateNotificationSettingsPayload), + args: { + input: { type: GT.NonNull(AccountDisableNotificationCategoryInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { channel, category } = args.input + + if (channel instanceof Error) return { errors: [{ message: channel.message }] } + + const result = await Accounts.disableNotificationCategory({ + accountId: domainAccount.id, + notificationChannel: channel, + notificationCategory: category, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + account: result, + } + }, +}) + +export default AccountDisableNotificationCategoryMutation diff --git a/src/graphql/public/root/mutation/account-disable-notification-channel.ts b/src/graphql/public/root/mutation/account-disable-notification-channel.ts new file mode 100644 index 0000000000..52fcbfc49e --- /dev/null +++ b/src/graphql/public/root/mutation/account-disable-notification-channel.ts @@ -0,0 +1,54 @@ +import { GT } from "@graphql/index" + +import AccountUpdateNotificationSettingsPayload from "@graphql/public/types/payload/account-update-notification-settings" +import { Accounts } from "@app/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import NotificationChannel from "@graphql/shared/types/scalar/notification-channel" + +const AccountDisableNotificationChannelInput = GT.Input({ + name: "AccountDisableNotificationChannelInput", + fields: () => ({ + channel: { + type: GT.NonNull(NotificationChannel), + }, + }), +}) + +const AccountDisableNotificationChannelMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + channel: NotificationChannel | Error + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(AccountUpdateNotificationSettingsPayload), + args: { + input: { type: GT.NonNull(AccountDisableNotificationChannelInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { channel } = args.input + + if (channel instanceof Error) return { errors: [{ message: channel.message }] } + + const result = await Accounts.disableNotificationChannel({ + accountId: domainAccount.id, + notificationChannel: channel, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + account: result, + } + }, +}) + +export default AccountDisableNotificationChannelMutation diff --git a/src/graphql/public/root/mutation/account-enable-notification-category-for-channel.ts b/src/graphql/public/root/mutation/account-enable-notification-category-for-channel.ts new file mode 100644 index 0000000000..f0b309e3a4 --- /dev/null +++ b/src/graphql/public/root/mutation/account-enable-notification-category-for-channel.ts @@ -0,0 +1,60 @@ +import { GT } from "@graphql/index" + +import AccountUpdateNotificationSettingsPayload from "@graphql/public/types/payload/account-update-notification-settings" +import { Accounts } from "@app/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import NotificationChannel from "@graphql/shared/types/scalar/notification-channel" +import NotificationCategory from "@graphql/shared/types/scalar/notification-category" + +const AccountEnableNotificationCategoryInput = GT.Input({ + name: "AccountEnableNotificationCategoryInput", + fields: () => ({ + channel: { + type: NotificationChannel, + }, + category: { + type: GT.NonNull(NotificationCategory), + }, + }), +}) + +const AccountEnableNotificationCategoryMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + channel?: NotificationChannel | Error + category: NotificationCategory + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(AccountUpdateNotificationSettingsPayload), + args: { + input: { type: GT.NonNull(AccountEnableNotificationCategoryInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { channel, category } = args.input + + if (channel instanceof Error) return { errors: [{ message: channel.message }] } + + const result = await Accounts.enableNotificationCategory({ + accountId: domainAccount.id, + notificationChannel: channel, + notificationCategory: category, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + account: result, + } + }, +}) + +export default AccountEnableNotificationCategoryMutation diff --git a/src/graphql/public/root/mutation/account-enable-notification-channel.ts b/src/graphql/public/root/mutation/account-enable-notification-channel.ts new file mode 100644 index 0000000000..3102a2656d --- /dev/null +++ b/src/graphql/public/root/mutation/account-enable-notification-channel.ts @@ -0,0 +1,54 @@ +import { GT } from "@graphql/index" + +import AccountUpdateNotificationSettingsPayload from "@graphql/public/types/payload/account-update-notification-settings" +import { Accounts } from "@app/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import NotificationChannel from "@graphql/shared/types/scalar/notification-channel" + +const AccountEnableNotificationChannelInput = GT.Input({ + name: "AccountEnableNotificationChannelInput", + fields: () => ({ + channel: { + type: GT.NonNull(NotificationChannel), + }, + }), +}) + +const AccountEnableNotificationChannelMutation = GT.Field< + null, + GraphQLPublicContextAuth, + { + input: { + channel: NotificationChannel | Error + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(AccountUpdateNotificationSettingsPayload), + args: { + input: { type: GT.NonNull(AccountEnableNotificationChannelInput) }, + }, + resolve: async (_, args, { domainAccount }: { domainAccount: Account }) => { + const { channel } = args.input + + if (channel instanceof Error) return { errors: [{ message: channel.message }] } + + const result = await Accounts.enableNotificationChannel({ + accountId: domainAccount.id, + notificationChannel: channel, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { + errors: [], + account: result, + } + }, +}) + +export default AccountEnableNotificationChannelMutation diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index aceb12b117..681fca2e67 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -6,6 +6,7 @@ interface Account { id: ID! level: AccountLevel! limits: AccountLimits! + notificationSettings: NotificationSettings! realtimePrice: RealtimePrice! transactions( """Returns the items in the list that come after the specified cursor.""" @@ -29,6 +30,24 @@ type AccountDeletePayload { success: Boolean! } +input AccountDisableNotificationCategoryInput { + category: NotificationCategory! + channel: NotificationChannel +} + +input AccountDisableNotificationChannelInput { + channel: NotificationChannel! +} + +input AccountEnableNotificationCategoryInput { + category: NotificationCategory! + channel: NotificationChannel +} + +input AccountEnableNotificationChannelInput { + channel: NotificationChannel! +} + enum AccountLevel { ONE TWO @@ -79,6 +98,11 @@ type AccountUpdateDisplayCurrencyPayload { errors: [Error!]! } +type AccountUpdateNotificationSettingsPayload { + account: ConsumerAccount + errors: [Error!]! +} + """An Opaque Bearer token""" scalar AuthToken @@ -198,6 +222,7 @@ type ConsumerAccount implements Account { id: ID! level: AccountLevel! limits: AccountLimits! + notificationSettings: NotificationSettings! """List the quiz questions of the consumer account""" quiz: [Quiz!]! @@ -620,6 +645,10 @@ type MobileVersions { type Mutation { accountDelete: AccountDeletePayload! + accountDisableNotificationCategory(input: AccountDisableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! + accountDisableNotificationChannel(input: AccountDisableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! + accountEnableNotificationCategory(input: AccountEnableNotificationCategoryInput!): AccountUpdateNotificationSettingsPayload! + accountEnableNotificationChannel(input: AccountEnableNotificationChannelInput!): AccountUpdateNotificationSettingsPayload! accountUpdateDefaultWalletId(input: AccountUpdateDefaultWalletIdInput!): AccountUpdateDefaultWalletIdPayload! accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! callbackEndpointAdd(input: CallbackEndpointAddInput!): CallbackEndpointAddPayload! @@ -749,6 +778,21 @@ enum Network { testnet } +scalar NotificationCategory + +enum NotificationChannel { + PUSH +} + +type NotificationChannelSettings { + disabledCategories: [NotificationCategory!]! + enabled: Boolean! +} + +type NotificationSettings { + push: NotificationChannelSettings! +} + """An address for an on-chain bitcoin destination""" scalar OnChainAddress diff --git a/src/graphql/public/types/abstract/account.ts b/src/graphql/public/types/abstract/account.ts index 9e19df3349..8b96aadd8f 100644 --- a/src/graphql/public/types/abstract/account.ts +++ b/src/graphql/public/types/abstract/account.ts @@ -11,6 +11,7 @@ import AccountLevel from "../../../shared/types/scalar/account-level" import Wallet from "../../../shared/types/abstract/wallet" import CallbackEndpoint from "../object/callback-endpoint" +import { NotificationSettings } from "../object/notification-settings" const IAccount = GT.Interface({ name: "Account", @@ -56,6 +57,9 @@ const IAccount = GT.Interface({ }, }, }, + notificationSettings: { + type: GT.NonNull(NotificationSettings), + }, // FUTURE-PLAN: Support a `users: [User!]!` field here }), diff --git a/src/graphql/public/types/object/business-account.ts b/src/graphql/public/types/object/business-account.ts index 1b93d01d6f..934c4c52e7 100644 --- a/src/graphql/public/types/object/business-account.ts +++ b/src/graphql/public/types/object/business-account.ts @@ -28,6 +28,7 @@ import AccountLevel from "../../../shared/types/scalar/account-level" import { TransactionConnection } from "../../../shared/types/object/transaction" import RealtimePrice from "./realtime-price" +import { NotificationSettings } from "./notification-settings" const BusinessAccount = GT.Object({ name: "BusinessAccount", @@ -159,6 +160,10 @@ const BusinessAccount = GT.Object({ ) }, }, + notificationSettings: { + type: GT.NonNull(NotificationSettings), + resolve: (source) => source.notificationSettings, + }, }), }) diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index 17bc5ab557..64acb10e1c 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -32,6 +32,7 @@ import { TransactionConnection } from "../../../shared/types/object/transaction" import AccountLimits from "./account-limits" import Quiz from "./quiz" import CallbackEndpoint from "./callback-endpoint" +import { NotificationSettings } from "./notification-settings" const ConsumerAccount = GT.Object({ name: "ConsumerAccount", @@ -182,6 +183,11 @@ const ConsumerAccount = GT.Object({ ) }, }, + + notificationSettings: { + type: GT.NonNull(NotificationSettings), + resolve: (source) => source.notificationSettings, + }, }), }) diff --git a/src/graphql/public/types/object/notification-channel-settings.ts b/src/graphql/public/types/object/notification-channel-settings.ts new file mode 100644 index 0000000000..09d965b5a1 --- /dev/null +++ b/src/graphql/public/types/object/notification-channel-settings.ts @@ -0,0 +1,19 @@ +import { GT } from "@graphql/index" +import NotificationCategory from "@graphql/shared/types/scalar/notification-category" + +export const NotificationChannelSettings = GT.Object< + NotificationChannelSettings, + GraphQLPublicContextAuth +>({ + name: "NotificationChannelSettings", + fields: () => ({ + enabled: { + type: GT.NonNull(GT.Boolean), + resolve: (source) => source.enabled, + }, + disabledCategories: { + type: GT.NonNullList(NotificationCategory), + resolve: (source) => source.disabledCategories, + }, + }), +}) diff --git a/src/graphql/public/types/object/notification-settings.ts b/src/graphql/public/types/object/notification-settings.ts new file mode 100644 index 0000000000..231238ba5b --- /dev/null +++ b/src/graphql/public/types/object/notification-settings.ts @@ -0,0 +1,16 @@ +import { GT } from "@graphql/index" + +import { NotificationChannelSettings } from "./notification-channel-settings" + +export const NotificationSettings = GT.Object< + NotificationSettings, + GraphQLPublicContextAuth +>({ + name: "NotificationSettings", + fields: () => ({ + push: { + type: GT.NonNull(NotificationChannelSettings), + resolve: (source) => source.push, + }, + }), +}) diff --git a/src/graphql/public/types/payload/account-update-notification-settings.ts b/src/graphql/public/types/payload/account-update-notification-settings.ts new file mode 100644 index 0000000000..0ef91e3124 --- /dev/null +++ b/src/graphql/public/types/payload/account-update-notification-settings.ts @@ -0,0 +1,18 @@ +import { GT } from "@graphql/index" + +import IError from "../../../shared/types/abstract/error" +import ConsumerAccount from "../object/consumer-account" + +const AccountUpdateNotificationSettingsPayload = GT.Object({ + name: "AccountUpdateNotificationSettingsPayload", + fields: () => ({ + errors: { + type: GT.NonNullList(IError), + }, + account: { + type: ConsumerAccount, + }, + }), +}) + +export default AccountUpdateNotificationSettingsPayload diff --git a/src/graphql/shared/types/scalar/notification-category.ts b/src/graphql/shared/types/scalar/notification-category.ts new file mode 100644 index 0000000000..27207eeccd --- /dev/null +++ b/src/graphql/shared/types/scalar/notification-category.ts @@ -0,0 +1,28 @@ +import { InputValidationError } from "@graphql/error" +import { GT } from "@graphql/index" + +const NotificationCategory = GT.Scalar({ + name: "NotificationCategory", + parseValue(value) { + if (typeof value !== "string") { + return new InputValidationError({ + message: "Invalid type for NotificationCategory", + }) + } + return validNotificationCategory(value) + }, + parseLiteral(ast) { + if (ast.kind === GT.Kind.STRING) { + return validNotificationCategory(ast.value) + } + return new InputValidationError({ message: "Invalid type for NotificationCategory" }) + }, +}) + +function validNotificationCategory( + value: string, +): NotificationCategory | InputValidationError { + return value as NotificationCategory +} + +export default NotificationCategory diff --git a/src/graphql/shared/types/scalar/notification-channel.ts b/src/graphql/shared/types/scalar/notification-channel.ts new file mode 100644 index 0000000000..8481ea5205 --- /dev/null +++ b/src/graphql/shared/types/scalar/notification-channel.ts @@ -0,0 +1,11 @@ +import { NotificationChannel as NotificationChannelDomain } from "@domain/notifications" +import { GT } from "@graphql/index" + +const NotificationChannel = GT.Enum({ + name: "NotificationChannel", + values: { + PUSH: { value: NotificationChannelDomain.Push }, + }, +}) + +export default NotificationChannel diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index ccf44c7b09..f66958022c 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -118,6 +118,7 @@ export const AccountsRepository = (): IAccountsRepository => { withdrawFee, kratosUserId, displayCurrency, + notificationSettings, role, }: Account): Promise => { @@ -142,6 +143,7 @@ export const AccountsRepository = (): IAccountsRepository => { withdrawFee, kratosUserId, displayCurrency, + notificationSettings, role, }, @@ -225,7 +227,14 @@ const translateToAccount = (result: AccountRecord): Account => ({ ), withdrawFee: result.withdrawFee as Satoshis, isEditor: result.role === "editor", - + notificationSettings: { + push: { + enabled: result.notificationSettings + ? result.notificationSettings.push.enabled + : true, + disabledCategories: result.notificationSettings?.push?.disabledCategories || [], + }, + }, // TODO: remove quizQuestions: result.earn?.map( diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 0ae905ad11..7b6bc919f5 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -259,6 +259,22 @@ const AccountSchema = new Schema( }, ], }, + notificationSettings: { + type: { + push: { + type: { + enabled: { + type: Boolean, + default: true, + }, + disabledCategories: { + type: [String], + default: [], + }, + }, + }, + }, + }, defaultWalletId: { type: String, diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 21e23ad961..d6b87d4662 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -84,6 +84,7 @@ interface AccountRecord { onchain: OnChainObjectForUser[] defaultWalletId: WalletId displayCurrency?: string + notificationSettings?: NotificationSettings // business: title?: string diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index 25908018de..f6fd773879 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -2,7 +2,11 @@ import { toSats } from "@domain/bitcoin" import { WalletCurrency } from "@domain/shared" import { toCents, UsdDisplayCurrency } from "@domain/fiat" import { customPubSubTrigger, PubSubDefaultTriggers } from "@domain/pubsub" -import { NotificationsServiceError, NotificationType } from "@domain/notifications" +import { + GaloyNotificationCategories, + NotificationsServiceError, + NotificationType, +} from "@domain/notifications" import { PubSubService } from "@services/pubsub" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" @@ -24,6 +28,7 @@ export const NotificationsService = (): INotificationsService => { displayPaymentAmount, paymentHash, recipientDeviceTokens, + recipientNotificationSettings, recipientLanguage, }: LightningTxReceivedArgs): Promise => { try { @@ -54,6 +59,8 @@ export const NotificationsService = (): INotificationsService => { }) if (recipientDeviceTokens && recipientDeviceTokens.length > 0) { + const notificationCategory = GaloyNotificationCategories.Payments + const { title, body } = createPushNotificationContent({ type: NotificationType.LnInvoicePaid, userLanguage: recipientLanguage, @@ -61,11 +68,19 @@ export const NotificationsService = (): INotificationsService => { displayAmount: displayPaymentAmount, }) - return pushNotification.sendNotification({ + const result = await pushNotification.sendFilteredNotification({ deviceTokens: recipientDeviceTokens, title, body, + notificationCategory, + notificationSettings: recipientNotificationSettings, }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return true } return true @@ -80,6 +95,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, recipientDeviceTokens, + recipientNotificationSettings, recipientLanguage, }: IntraLedgerTxReceivedArgs): Promise => { try { @@ -114,6 +130,8 @@ export const NotificationsService = (): INotificationsService => { }) if (recipientDeviceTokens && recipientDeviceTokens.length > 0) { + const notificationCategory = GaloyNotificationCategories.Payments + const { title, body } = createPushNotificationContent({ type: NotificationType.IntraLedgerReceipt, userLanguage: recipientLanguage, @@ -121,11 +139,19 @@ export const NotificationsService = (): INotificationsService => { displayAmount: displayPaymentAmount, }) - return pushNotification.sendNotification({ + const result = await pushNotification.sendFilteredNotification({ deviceTokens: recipientDeviceTokens, title, body, + notificationCategory, + notificationSettings: recipientNotificationSettings, }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return true } return true @@ -141,6 +167,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, deviceTokens, + notificationSettings, language, txHash, }: { @@ -150,6 +177,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount: PaymentAmount displayPaymentAmount?: DisplayAmount deviceTokens: DeviceToken[] + notificationSettings: NotificationSettings language: UserLanguageOrEmpty txHash: OnChainTxHash }): Promise => { @@ -180,6 +208,8 @@ export const NotificationsService = (): INotificationsService => { }) if (deviceTokens.length > 0) { + const notificationCategory = GaloyNotificationCategories.Payments + const { title, body } = createPushNotificationContent({ type, userLanguage: language, @@ -187,11 +217,19 @@ export const NotificationsService = (): INotificationsService => { displayAmount: displayPaymentAmount, }) - return pushNotification.sendNotification({ + const result = await pushNotification.sendFilteredNotification({ deviceTokens, title, body, + notificationCategory, + notificationSettings, }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return true } return true @@ -206,6 +244,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, recipientDeviceTokens, + recipientNotificationSettings, recipientLanguage, txHash, }: OnChainTxReceivedArgs) => @@ -216,6 +255,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, deviceTokens: recipientDeviceTokens, + notificationSettings: recipientNotificationSettings, language: recipientLanguage, txHash, }) @@ -226,6 +266,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, recipientDeviceTokens, + recipientNotificationSettings, recipientLanguage, txHash, }: OnChainTxReceivedPendingArgs) => @@ -236,6 +277,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, deviceTokens: recipientDeviceTokens, + notificationSettings: recipientNotificationSettings, language: recipientLanguage, txHash, }) @@ -246,6 +288,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, senderDeviceTokens, + senderNotificationSettings, senderLanguage, txHash, }: OnChainTxSentArgs) => @@ -256,6 +299,7 @@ export const NotificationsService = (): INotificationsService => { paymentAmount, displayPaymentAmount, deviceTokens: senderDeviceTokens, + notificationSettings: senderNotificationSettings, language: senderLanguage, txHash, }) @@ -295,6 +339,7 @@ export const NotificationsService = (): INotificationsService => { const sendBalance = async ({ balanceAmount, deviceTokens, + notificationSettings, displayBalanceAmount, recipientLanguage, }: SendBalanceArgs): Promise => { @@ -302,6 +347,8 @@ export const NotificationsService = (): INotificationsService => { if (!hasDeviceTokens) return true try { + const notificationCategory = GaloyNotificationCategories.Payments + const { title, body } = createPushNotificationContent({ type: "balance", userLanguage: recipientLanguage, @@ -309,11 +356,19 @@ export const NotificationsService = (): INotificationsService => { displayAmount: displayBalanceAmount, }) - return pushNotification.sendNotification({ + const result = await pushNotification.sendFilteredNotification({ deviceTokens, title, body, + notificationCategory, + notificationSettings, }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return true } catch (err) { return handleCommonNotificationErrors(err) } @@ -340,6 +395,37 @@ export const NotificationsService = (): INotificationsService => { } } + const adminPushNotificationFilteredSend = async ({ + title, + body, + data, + deviceTokens, + notificationSettings, + notificationCategory, + }: SendFilteredPushNotificationArgs): Promise => { + const hasDeviceTokens = deviceTokens && deviceTokens.length > 0 + if (!hasDeviceTokens) return true + + try { + const result = await pushNotification.sendFilteredNotification({ + deviceTokens, + title, + body, + data, + notificationSettings, + notificationCategory, + }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return true + } catch (err) { + return handleCommonNotificationErrors(err) + } + } + // trace everything except price update because it runs every 30 seconds return { priceUpdate, @@ -353,6 +439,7 @@ export const NotificationsService = (): INotificationsService => { onChainTxSent, sendBalance, adminPushNotificationSend, + adminPushNotificationFilteredSend, }, }), } diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 436d1de5ae..2af84e2c76 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -3,8 +3,11 @@ import * as admin from "firebase-admin" import { DeviceTokensNotRegisteredNotificationsServiceError, InvalidDeviceNotificationsServiceError, + NotificationChannel, + NotificationsServiceError, NotificationsServiceUnreachableServerError, UnknownNotificationsServiceError, + shouldSendNotification, } from "@domain/notifications" import { ErrorLevel, parseErrorMessageFromUnknown } from "@domain/shared" import { baseLogger } from "@services/logger" @@ -112,7 +115,40 @@ export const PushNotificationsService = (): IPushNotificationsService => { })() } - return { sendNotification } + const sendFilteredNotification = async (args: SendFilteredPushNotificationArgs) => { + const { notificationSettings, notificationCategory, data, ...sendNotificationArgs } = + args + + if ( + !shouldSendNotification({ + notificationCategory, + notificationSettings, + notificationChannel: NotificationChannel.Push, + }) + ) { + return { + status: SendFilteredPushNotificationStatus.Filtered, + } + } + + const result = await sendNotification({ + ...sendNotificationArgs, + data: { + ...data, + NotificationCategory: notificationCategory, + }, + }) + + if (result instanceof NotificationsServiceError) { + return result + } + + return { + status: SendFilteredPushNotificationStatus.Sent, + } + } + + return { sendNotification, sendFilteredNotification } } export const handleCommonNotificationErrors = (err: Error | string | unknown) => { @@ -134,3 +170,8 @@ export const KnownNotificationErrorMessages = { GoogleBadGatewayError: /Raw server response .* Error 502/, GoogleInternalServerError: /Raw server response .* Error 500/, } as const + +export const SendFilteredPushNotificationStatus = { + Sent: "Sent", + Filtered: "Filtered", +} as const diff --git a/src/services/notifications/push-notifications.types.d.ts b/src/services/notifications/push-notifications.types.d.ts index 84844bb07a..c8a8dd928f 100644 --- a/src/services/notifications/push-notifications.types.d.ts +++ b/src/services/notifications/push-notifications.types.d.ts @@ -5,6 +5,18 @@ type SendPushNotificationArgs = { data?: { [key: string]: string } } +type SendFilteredPushNotificationArgs = { + deviceTokens: DeviceToken[] + title: string + body: string + data?: { [key: string]: string } + notificationSettings: NotificationSettings + notificationCategory: NotificationCategory +} + +type SendFilteredPushNotificationStatus = + (typeof import("./push-notifications").SendFilteredPushNotificationStatus)[keyof typeof import("./push-notifications").SendFilteredPushNotificationStatus] + interface IPushNotificationsService { sendNotification({ deviceTokens, @@ -12,4 +24,11 @@ interface IPushNotificationsService { body, data, }: SendPushNotificationArgs): Promise + + sendFilteredNotification(args: SendFilteredPushNotificationArgs): Promise< + | { + status: SendFilteredPushNotificationStatus + } + | NotificationsServiceError + > } diff --git a/test/bats/admin.bats b/test/bats/admin.bats index b141ef1d99..8b6792ce4f 100644 --- a/test/bats/admin.bats +++ b/test/bats/admin.bats @@ -121,4 +121,4 @@ TESTER_PHONE="+19876543210" # TODO: add check by email # TODO: business update map info -} \ No newline at end of file +} diff --git a/test/bats/gql/account-disable-notification-category.gql b/test/bats/gql/account-disable-notification-category.gql new file mode 100644 index 0000000000..771a6c7e9f --- /dev/null +++ b/test/bats/gql/account-disable-notification-category.gql @@ -0,0 +1,15 @@ +mutation accountDisableNotificationCategory($input: AccountDisableNotificationCategoryInput!) { + accountDisableNotificationCategory(input: $input) { + errors { + message + } + account { + notificationSettings { + push { + enabled + disabledCategories + } + } + } + } +} diff --git a/test/bats/gql/account-disable-notification-channel.gql b/test/bats/gql/account-disable-notification-channel.gql new file mode 100644 index 0000000000..34e9a5795b --- /dev/null +++ b/test/bats/gql/account-disable-notification-channel.gql @@ -0,0 +1,15 @@ +mutation accountDisableNotificationChannel($input: AccountDisableNotificationChannelInput!) { + accountDisableNotificationChannel(input: $input) { + errors { + message + } + account { + notificationSettings { + push { + enabled + disabledCategories + } + } + } + } +} diff --git a/test/bats/gql/account-enable-notification-category.gql b/test/bats/gql/account-enable-notification-category.gql new file mode 100644 index 0000000000..5b5b752fe9 --- /dev/null +++ b/test/bats/gql/account-enable-notification-category.gql @@ -0,0 +1,15 @@ +mutation accountEnableNotificationCategory($input: AccountEnableNotificationCategoryInput!) { + accountEnableNotificationCategory(input: $input) { + errors { + message + } + account { + notificationSettings { + push { + enabled + disabledCategories + } + } + } + } +} diff --git a/test/bats/gql/account-enable-notification-channel.gql b/test/bats/gql/account-enable-notification-channel.gql new file mode 100644 index 0000000000..a3593270dc --- /dev/null +++ b/test/bats/gql/account-enable-notification-channel.gql @@ -0,0 +1,15 @@ +mutation accountEnableNotificationChannel($input: AccountEnableNotificationChannelInput!) { + accountEnableNotificationChannel(input: $input) { + errors { + message + } + account { + notificationSettings { + push { + enabled + disabledCategories + } + } + } + } +} diff --git a/test/bats/notification-settings.bats b/test/bats/notification-settings.bats new file mode 100644 index 0000000000..3496ff27e8 --- /dev/null +++ b/test/bats/notification-settings.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats + +load "helpers/setup-and-teardown" + +setup_file() { + clear_cache + reset_redis + + bitcoind_init + start_trigger + start_server + + initialize_user_from_onchain "$ALICE_TOKEN_NAME" "$ALICE_PHONE" "$CODE" +} + +teardown_file() { + stop_trigger + stop_server +} + + +@test "notification-settings: disable/enable notification channel" { + token_name="$ALICE_TOKEN_NAME" + + variables=$( + jq -n \ + '{input: { channel: "PUSH" }}') + + exec_graphql "$token_name" 'account-disable-notification-channel' "$variables" + channel_enabled="$(graphql_output '.data.accountDisableNotificationChannel.account.notificationSettings.push.enabled')" + [[ "$channel_enabled" == "false" ]] || exit 1 + + exec_graphql "$token_name" 'account-enable-notification-channel' "$variables" + + channel_enabled="$(graphql_output '.data.accountEnableNotificationChannel.account.notificationSettings.push.enabled')" + [[ "$channel_enabled" == "true" ]] || exit 1 +} + +@test "notification-settings: disable/enable notification category" { + token_name="$ALICE_TOKEN_NAME" + + variables=$( + jq -n \ + '{input: { channel: "PUSH", category: "Circles" }}') + + exec_graphql "$token_name" 'account-disable-notification-category' "$variables" + disabled_category="$(graphql_output '.data.accountDisableNotificationCategory.account.notificationSettings.push.disabledCategories[0]')" + [[ "$disabled_category" == "Circles" ]] || exit 1 + + exec_graphql "$token_name" 'account-enable-notification-category' "$variables" + + disabled_length="$(graphql_output '.data.accountEnableNotificationCategory.account.notificationSettings.push.disabledCategories | length')" + [[ "$disabled_length" == "0" ]] || exit 1 +} diff --git a/test/integration/app/wallets/add-pending-on-chain-transaction.spec.ts b/test/integration/app/wallets/add-pending-on-chain-transaction.spec.ts index 3a6c770cd9..bdef931412 100644 --- a/test/integration/app/wallets/add-pending-on-chain-transaction.spec.ts +++ b/test/integration/app/wallets/add-pending-on-chain-transaction.spec.ts @@ -22,12 +22,15 @@ afterEach(async () => { }) describe("addPendingTransaction", () => { - it("calls sendNotification on pending onchain receive", async () => { + it("calls sendFilteredNotification on pending onchain receive", async () => { // Setup mocks - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() const pushNotificationsServiceSpy = jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementationOnce(() => ({ sendNotification })) + .mockImplementationOnce(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) // Create user const { btcWalletDescriptor } = await createRandomUserAndWallets() @@ -47,8 +50,8 @@ describe("addPendingTransaction", () => { }) // Expect sent notification - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBeTruthy() + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBeTruthy() // Restore system state pushNotificationsServiceSpy.mockRestore() diff --git a/test/integration/app/wallets/add-settled-on-chain-transaction.spec.ts b/test/integration/app/wallets/add-settled-on-chain-transaction.spec.ts index 69e1268cbe..b8636a21ab 100644 --- a/test/integration/app/wallets/add-settled-on-chain-transaction.spec.ts +++ b/test/integration/app/wallets/add-settled-on-chain-transaction.spec.ts @@ -28,12 +28,15 @@ afterEach(async () => { }) describe("addSettledTransaction", () => { - it("calls sendNotification on successful onchain receive", async () => { + it("calls sendFilteredNotification on successful onchain receive", async () => { // Setup mocks - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() const pushNotificationsServiceSpy = jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementationOnce(() => ({ sendNotification })) + .mockImplementationOnce(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) // Create user const { btcWalletDescriptor } = await createRandomUserAndWallets() @@ -53,8 +56,8 @@ describe("addSettledTransaction", () => { }) // Expect sent notification - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBeTruthy() + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBeTruthy() // Restore system state pushNotificationsServiceSpy.mockRestore() diff --git a/test/integration/app/wallets/send-intraledger.spec.ts b/test/integration/app/wallets/send-intraledger.spec.ts index 6862eaa9a9..f6bea1bd0f 100644 --- a/test/integration/app/wallets/send-intraledger.spec.ts +++ b/test/integration/app/wallets/send-intraledger.spec.ts @@ -256,12 +256,15 @@ describe("intraLedgerPay", () => { expect(paymentResult).toBeInstanceOf(IntraledgerLimitsExceededError) }) - it("calls sendNotification on successful intraledger send", async () => { + it("calls sendFilteredNotification on successful intraledger send", async () => { // Setup mocks - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() const pushNotificationsServiceSpy = jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementationOnce(() => ({ sendNotification })) + .mockImplementationOnce(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) // Create users const { btcWalletDescriptor: newWalletDescriptor, usdWalletDescriptor } = @@ -290,8 +293,8 @@ describe("intraLedgerPay", () => { expect(paymentResult).toEqual(PaymentSendStatus.Success) // Expect sent notification - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBeTruthy() + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBeTruthy() // Restore system state pushNotificationsServiceSpy.mockReset() diff --git a/test/integration/app/wallets/send-lightning.spec.ts b/test/integration/app/wallets/send-lightning.spec.ts index ee61da9547..6be024fa1c 100644 --- a/test/integration/app/wallets/send-lightning.spec.ts +++ b/test/integration/app/wallets/send-lightning.spec.ts @@ -745,12 +745,15 @@ describe("initiated via lightning", () => { lndServiceSpy.mockReset() }) - it("calls sendNotification on successful intraledger send", async () => { + it("calls sendFilteredNotification on successful intraledger send", async () => { // Setup mocks - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() const pushNotificationsServiceSpy = jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementationOnce(() => ({ sendNotification })) + .mockImplementationOnce(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) const { LndService: LnServiceOrig } = jest.requireActual("@services/lnd") const lndServiceSpy = jest.spyOn(LndImpl, "LndService").mockReturnValue({ @@ -799,8 +802,8 @@ describe("initiated via lightning", () => { expect(paymentResult).toEqual(PaymentSendStatus.Success) // Expect sent notification - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBeTruthy() + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBeTruthy() // Restore system state pushNotificationsServiceSpy.mockRestore() diff --git a/test/integration/app/wallets/send-onchain.spec.ts b/test/integration/app/wallets/send-onchain.spec.ts index 998bfc3ffa..18075f3faf 100644 --- a/test/integration/app/wallets/send-onchain.spec.ts +++ b/test/integration/app/wallets/send-onchain.spec.ts @@ -551,12 +551,15 @@ describe("onChainPay", () => { expect(res).toBeInstanceOf(InactiveAccountError) }) - it("calls sendNotification on successful intraledger receive", async () => { + it("calls sendFilteredNotification on successful intraledger receive", async () => { // Setup mocks - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() const pushNotificationsServiceSpy = jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementationOnce(() => ({ sendNotification })) + .mockImplementationOnce(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) // Create users const { btcWalletDescriptor: newWalletDescriptor, usdWalletDescriptor } = @@ -595,8 +598,8 @@ describe("onChainPay", () => { expect(paymentResult.status).toEqual(PaymentSendStatus.Success) // Expect sent notification - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBeTruthy() + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBeTruthy() // Restore system state pushNotificationsServiceSpy.mockRestore() diff --git a/test/legacy-integration/notifications/notification.spec.ts b/test/legacy-integration/notifications/notification.spec.ts index 149140fd6f..d5df3ccaea 100644 --- a/test/legacy-integration/notifications/notification.spec.ts +++ b/test/legacy-integration/notifications/notification.spec.ts @@ -18,6 +18,7 @@ import { getCurrentPriceAsDisplayPriceRatio, } from "@app/prices" import { WalletCurrency } from "@domain/shared" +import { GaloyNotificationCategories } from "@domain/notifications" let spy let displayPriceRatios: Record> @@ -43,6 +44,13 @@ const crcDisplayPaymentAmount = { displayInMajor: "3500.50", } +const unfilteredNotificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: [], + }, +} + beforeAll(async () => { const usdDisplayPriceRatio = await getCurrentPriceAsDisplayPriceRatio({ currency: UsdDisplayCurrency, @@ -90,16 +98,19 @@ async function toArray(gen: AsyncIterable): Promise { } describe("notification", () => { - describe("sendNotification", () => { + describe("sendFilteredNotification", () => { // FIXME // 1/ we don't use this code in production any more // 2/ this is a very convoluted test that relies on other tests as an artefact. // It's hard to debug. it's probably something we'll want to refactor with more cleaner/independant integration tests. it.skip("sends daily balance to active users", async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") - .mockImplementation(() => ({ sendNotification })) + .mockImplementation(() => ({ + sendFilteredNotification, + sendNotification: jest.fn(), + })) await sendDefaultWalletBalanceToAccounts() const activeAccounts = getRecentlyActiveAccounts() @@ -108,7 +119,7 @@ describe("notification", () => { const activeAccountsArray = await toArray(activeAccounts) expect(activeAccountsArray.length).toBeGreaterThan(0) - expect(sendNotification.mock.calls.length).toBeGreaterThan(0) + expect(sendFilteredNotification.mock.calls.length).toBeGreaterThan(0) let usersWithDeviceTokens = 0 for (const { kratosUserId } of activeAccountsArray) { @@ -118,10 +129,10 @@ describe("notification", () => { if (user.deviceTokens.length > 0) usersWithDeviceTokens++ } - expect(sendNotification.mock.calls.length).toBe(usersWithDeviceTokens) + expect(sendFilteredNotification.mock.calls.length).toBe(usersWithDeviceTokens) - for (let i = 0; i < sendNotification.mock.calls.length; i++) { - const [call] = sendNotification.mock.calls[i] + for (let i = 0; i < sendFilteredNotification.mock.calls.length; i++) { + const [call] = sendFilteredNotification.mock.calls[i] const { defaultWalletId, kratosUserId } = activeAccountsArray[i] const user = await UsersRepository().findById(kratosUserId) @@ -196,11 +207,12 @@ describe("notification", () => { ] tests.forEach(({ name, paymentAmount, title, body }) => it(`${name}`, async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") .mockImplementationOnce(() => ({ - sendNotification, + sendFilteredNotification, + sendNotification: jest.fn(), })) await NotificationsService().lightningTxReceived({ @@ -211,12 +223,16 @@ describe("notification", () => { displayPaymentAmount: crcDisplayPaymentAmount, paymentHash, recipientDeviceTokens: deviceTokens, + recipientNotificationSettings: unfilteredNotificationSettings, recipientLanguage: language, }) - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBe(title) - expect(sendNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBe(title) + expect(sendFilteredNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls[0][0].notificationCategory).toBe( + GaloyNotificationCategories.Payments, + ) }), ) }) @@ -239,11 +255,12 @@ describe("notification", () => { tests.forEach(({ name, paymentAmount, title, body }) => it(`${name}`, async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") .mockImplementationOnce(() => ({ - sendNotification, + sendFilteredNotification, + sendNotification: jest.fn(), })) await NotificationsService().intraLedgerTxReceived({ @@ -253,12 +270,16 @@ describe("notification", () => { recipientWalletId: walletId, displayPaymentAmount: crcDisplayPaymentAmount, recipientDeviceTokens: deviceTokens, + recipientNotificationSettings: unfilteredNotificationSettings, recipientLanguage: language, }) - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBe(title) - expect(sendNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBe(title) + expect(sendFilteredNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls[0][0].notificationCategory).toBe( + GaloyNotificationCategories.Payments, + ) }), ) }) @@ -281,11 +302,12 @@ describe("notification", () => { tests.forEach(({ name, paymentAmount, title, body }) => it(`${name}`, async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") .mockImplementationOnce(() => ({ - sendNotification, + sendFilteredNotification, + sendNotification: jest.fn(), })) await NotificationsService().onChainTxReceived({ @@ -296,12 +318,16 @@ describe("notification", () => { displayPaymentAmount: crcDisplayPaymentAmount, txHash, recipientDeviceTokens: deviceTokens, + recipientNotificationSettings: unfilteredNotificationSettings, recipientLanguage: language, }) - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBe(title) - expect(sendNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBe(title) + expect(sendFilteredNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls[0][0].notificationCategory).toBe( + GaloyNotificationCategories.Payments, + ) }), ) }) @@ -324,11 +350,12 @@ describe("notification", () => { tests.forEach(({ name, paymentAmount, title, body }) => it(`${name}`, async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") .mockImplementationOnce(() => ({ - sendNotification, + sendFilteredNotification, + sendNotification: jest.fn(), })) await NotificationsService().onChainTxReceivedPending({ @@ -338,12 +365,16 @@ describe("notification", () => { txHash, displayPaymentAmount: crcDisplayPaymentAmount, recipientDeviceTokens: deviceTokens, + recipientNotificationSettings: unfilteredNotificationSettings, recipientLanguage: language, }) - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBe(title) - expect(sendNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBe(title) + expect(sendFilteredNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls[0][0].notificationCategory).toBe( + GaloyNotificationCategories.Payments, + ) }), ) }) @@ -366,11 +397,12 @@ describe("notification", () => { tests.forEach(({ name, paymentAmount, title, body }) => it(`${name}`, async () => { - const sendNotification = jest.fn() + const sendFilteredNotification = jest.fn() jest .spyOn(PushNotificationsServiceImpl, "PushNotificationsService") .mockImplementationOnce(() => ({ - sendNotification, + sendFilteredNotification, + sendNotification: jest.fn(), })) await NotificationsService().onChainTxSent({ @@ -380,12 +412,16 @@ describe("notification", () => { txHash, displayPaymentAmount: crcDisplayPaymentAmount, senderDeviceTokens: deviceTokens, + senderNotificationSettings: unfilteredNotificationSettings, senderLanguage: language, }) - expect(sendNotification.mock.calls.length).toBe(1) - expect(sendNotification.mock.calls[0][0].title).toBe(title) - expect(sendNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls.length).toBe(1) + expect(sendFilteredNotification.mock.calls[0][0].title).toBe(title) + expect(sendFilteredNotification.mock.calls[0][0].body).toBe(body) + expect(sendFilteredNotification.mock.calls[0][0].notificationCategory).toBe( + GaloyNotificationCategories.Payments, + ) }), ) }) diff --git a/test/legacy-integration/notifications/push-notification.spec.ts b/test/legacy-integration/notifications/push-notification.spec.ts new file mode 100644 index 0000000000..bcc71a2e1a --- /dev/null +++ b/test/legacy-integration/notifications/push-notification.spec.ts @@ -0,0 +1,28 @@ +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +describe("push notification", () => { + it("should filter a notification", async () => { + const notificationCategory = "transaction" as NotificationCategory + const notificationSettings = { + push: { + enabled: true, + disabledCategories: [notificationCategory], + }, + } + + const result = await PushNotificationsService().sendFilteredNotification({ + body: "body", + title: "title", + deviceTokens: ["deviceToken" as DeviceToken], + notificationCategory, + notificationSettings, + }) + + expect(result).toEqual({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + }) +}) diff --git a/test/unit/domain/notifications/index.spec.ts b/test/unit/domain/notifications/index.spec.ts new file mode 100644 index 0000000000..76d9d3b23f --- /dev/null +++ b/test/unit/domain/notifications/index.spec.ts @@ -0,0 +1,172 @@ +import { + NotificationChannel, + disableNotificationCategory, + enableNotificationChannel, + disableNotificationChannel, + shouldSendNotification, +} from "@domain/notifications" + +describe("Notifications - push notification filtering", () => { + describe("shouldSendPushNotification", () => { + it("returns false when push notifications are disabled", () => { + const notificationSettings: NotificationSettings = { + push: { + enabled: false, + disabledCategories: [], + }, + } + + const notificationCategory = "transaction" as NotificationCategory + + expect( + shouldSendNotification({ + notificationSettings, + notificationCategory, + notificationChannel: NotificationChannel.Push, + }), + ).toBe(false) + }) + + it("returns true when a notification is not disabled", () => { + const notificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: [], + }, + } + + const notificationCategory = "transaction" as NotificationCategory + + expect( + shouldSendNotification({ + notificationSettings, + notificationCategory, + notificationChannel: NotificationChannel.Push, + }), + ).toBe(true) + }) + + it("returns false when a notification is disabled", () => { + const notificationCategory = "transaction" as NotificationCategory + + const notificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: [notificationCategory], + }, + } + + expect( + shouldSendNotification({ + notificationSettings, + notificationCategory, + notificationChannel: NotificationChannel.Push, + }), + ).toBe(false) + }) + }) + + describe("enableNotificationChannel", () => { + it("clears disabled categories when enabling a channel", () => { + const notificationSettings: NotificationSettings = { + push: { + enabled: false, + disabledCategories: ["transaction" as NotificationCategory], + }, + } + + const notificationChannel = NotificationChannel.Push + + const result = enableNotificationChannel({ + notificationSettings, + notificationChannel, + }) + + expect(result).toEqual({ + push: { + enabled: true, + disabledCategories: [], + }, + }) + }) + }) + + describe("disableNotificationChannel", () => { + it("clears disabled categories when disabling a channel", () => { + const notificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: ["transaction" as NotificationCategory], + }, + } + + const notificationChannel = NotificationChannel.Push + + const result = disableNotificationChannel({ + notificationSettings, + notificationChannel, + }) + + expect(result).toEqual({ + push: { + enabled: false, + disabledCategories: [], + }, + }) + }) + }) + + describe("disableNotificationCategoryForChannel", () => { + it("adds a category to the disabled categories", () => { + const notificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: [], + }, + } + + const notificationChannel = NotificationChannel.Push + + const notificationCategory = "transaction" as NotificationCategory + + const result = disableNotificationCategory({ + notificationSettings, + notificationChannel, + notificationCategory, + }) + + expect(result).toEqual({ + push: { + enabled: true, + disabledCategories: [notificationCategory], + }, + }) + }) + + it("does not add a category to the disabled categories if it is already there", () => { + const notificationCategory = "transaction" as NotificationCategory + + const notificationSettings: NotificationSettings = { + push: { + enabled: true, + disabledCategories: [notificationCategory], + }, + } + + const notificationChannel = NotificationChannel.Push + + const result = disableNotificationCategory({ + notificationSettings, + notificationChannel, + notificationCategory, + }) + + expect(result).toEqual({ + push: { + enabled: true, + disabledCategories: [notificationCategory], + }, + }) + }) + }) +}) diff --git a/test/unit/domain/wallets/payment-input-validator.spec.ts b/test/unit/domain/wallets/payment-input-validator.spec.ts index 72b35c92d6..522043df41 100644 --- a/test/unit/domain/wallets/payment-input-validator.spec.ts +++ b/test/unit/domain/wallets/payment-input-validator.spec.ts @@ -22,6 +22,12 @@ describe("PaymentInputValidator", () => { latitude: 0, longitude: 0, }, + notificationSettings: { + push: { + enabled: true, + disabledCategories: [], + }, + }, contactEnabled: true, contacts: [], isEditor: false,