diff --git a/.env b/.env index cbce6645a..b899f396b 100644 --- a/.env +++ b/.env @@ -127,3 +127,5 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.local export IBEX_URL="https://api-sandbox.poweredbyibex.io" export IBEX_EMAIL="" export IBEX_PASSWORD="" + +export GOOGLE_APPLICATION_CREDENTIALS="./firebase-key.json" \ No newline at end of file diff --git a/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru b/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru new file mode 100644 index 000000000..603e7e8cb --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru @@ -0,0 +1,37 @@ +meta { + name: cashout-notification-send + type: graphql + seq: 2 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashoutNotificationSend($input: CashoutNotificationSendInput!) { + cashoutNotificationSend(input: $input) { + errors { + message + } + success + } + } +} + +body:graphql:vars { + { + "input": { + "accountId": "37590fbc-89b3-4218-abf6-5bded51b8fe7", + "amount": 100.0, + "currency": "JMD", + "notificationCategory": "Payments" + } + } +} diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index 95a73b1dd..9fd3d7c56 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -1,5 +1,6 @@ export * from "./update-user-phone" export * from "./send-admin-push-notification" +export * from "./send-cashout-notification" export * from "./send-broadcast-notification" import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts" diff --git a/src/app/admin/send-cashout-notification.ts b/src/app/admin/send-cashout-notification.ts new file mode 100644 index 000000000..7ea39a43f --- /dev/null +++ b/src/app/admin/send-cashout-notification.ts @@ -0,0 +1,62 @@ +import { checkedToAccountUuid } from "@domain/accounts" +import { checkedToNotificationCategory, GaloyNotificationCategories } from "@domain/notifications" +import { checkedToDeviceToken } from "@domain/users" +import { AccountsRepository, UsersRepository } from "@services/mongoose" +import { NotificationsService } from "@services/notifications" + +export const sendCashoutNotification = async ( + { + accountId: accountIdRaw, + title, + body, + amount, + currency, + notificationCategory, + deviceTokens + }: { + accountId: string, + title: string, + body: string, + amount: number, + currency: string, + notificationCategory?: string, + deviceTokens?: string[] + }): Promise => { + + const checkedNotificationCategory = notificationCategory ? checkedToNotificationCategory(notificationCategory) : GaloyNotificationCategories.Payments + + if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory + + const accountId = checkedToAccountUuid(accountIdRaw) + if (accountId instanceof Error) return accountId + + const accountsRepo = AccountsRepository() + const account = await accountsRepo.findByUuid(accountId) + if (account instanceof Error) return account + const kratosUserId = account.kratosUserId + + let tokens: DeviceToken[] = [] + if (deviceTokens && deviceTokens.length > 0) { + for (const token of deviceTokens) { + const checkedToken = await checkedToDeviceToken(token) + if (checkedToken instanceof Error) return checkedToken + tokens.push(checkedToken) + } + } else { + const usersRepo = UsersRepository() + const user = await usersRepo.findById(kratosUserId) + if (user instanceof Error) return user + tokens = user.deviceTokens + } + + const success = await NotificationsService().adminPushNotificationFilteredSend({ + deviceTokens: tokens, + title, + body, + data: { amount: amount.toString(), currency }, + notificationCategory: checkedNotificationCategory, + notificationSettings: account.notificationSettings, + }) + + return success +} \ No newline at end of file diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 3d5d5547a..c6849d01c 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -8,6 +8,7 @@ import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" import AdminPushNotificationSendMutation from "./root/mutation/admin-push-notification-send" import AdminBroadcastSendMutation from "./root/mutation/admin-broadcast-send" +import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send" import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" @@ -24,6 +25,7 @@ export const mutationFields = { businessUpdateMapInfo: BusinessUpdateMapInfoMutation, businessDeleteMapInfo: BusinessDeleteMapInfoMutation, adminPushNotificationSend: AdminPushNotificationSendMutation, + cashoutNotificationSend: sendCashoutSettledNotification, adminBroadcastSend: AdminBroadcastSendMutation, }, } diff --git a/src/graphql/admin/root/mutation/cashout-notification-send.ts b/src/graphql/admin/root/mutation/cashout-notification-send.ts new file mode 100644 index 000000000..66b2193cc --- /dev/null +++ b/src/graphql/admin/root/mutation/cashout-notification-send.ts @@ -0,0 +1,58 @@ +import { Admin } from "@app/index"; +import AdminPushNotificationSendPayload from "@graphql/admin/types/payload/admin-push-notification-send"; +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map"; +import { GT } from "@graphql/index"; +import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload"; +import NotificationCategory from "@graphql/shared/types/scalar/notification-category"; + +const CashoutNotificationSendInput = GT.Input({ + name: "CashoutNotificationSendInput", + fields: () => ({ + accountId: { + type: GT.NonNull(GT.String), + }, + amount: { + type: GT.NonNull(GT.Float), + }, + currency: { + type: GT.NonNull(GT.String) + }, + notificationCategory: { + type: NotificationCategory, + }, + }) +}) + +const sendCashoutSettledNotification = GT.Field({ + extensions: { + complexity: 1, + }, + type: GT.NonNull(AdminPushNotificationSendPayload), + args: { + input: { type: GT.NonNull(CashoutNotificationSendInput) } + }, + resolve: async (_, args) => { + + const { accountId, amount, currency, notificationCategory } = args.input; + + const title = "Cashout Successful" + const body = `Your cashout of $${amount.toFixed(2)} ${currency} has been processed.` + + const success = await Admin.sendAdminPushNotification({ + accountId, + title, + body, + data: { amount: String(amount), currency }, + notificationCategory + }) + + if (success instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(success)] } + } + + return SUCCESS_RESPONSE + + } +}) + +export default sendCashoutSettledNotification \ No newline at end of file diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 85b9f3043..016132bbb 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -146,6 +146,13 @@ input BusinessUpdateMapInfoInput { username: Username! } +input CashoutNotificationSendInput { + accountId: String! + amount: Float! + currency: String! + notificationCategory: NotificationCategory +} + type Coordinates { latitude: Float! longitude: Float! @@ -284,6 +291,7 @@ type Mutation { adminPushNotificationSend(input: AdminPushNotificationSendInput!): AdminPushNotificationSendPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! + cashoutNotificationSend(input: CashoutNotificationSendInput!): AdminPushNotificationSendPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload! diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 0835b3e1b..b91974c05 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -45,6 +45,7 @@ const sendToDevice = async ( batchResp.responses .forEach((r, idx) => { if (!r.success) { + logger.warn({ error: r.error, token: tokens[idx] }, "Error sending notification to device") recordExceptionInCurrentSpan({ error: new FirebaseMessageError(r.error as unknown as FirebaseError, tokens[idx]), level: ErrorLevel.Warn, @@ -55,6 +56,11 @@ const sendToDevice = async ( } }) + logger.info( + { successCount: batchResp.successCount, failureCount: batchResp.failureCount }, + "Notification batch response", + ) + // addAttributesToCurrentSpan({ // failureCount: response.failureCount, // successCount: response.successCount, diff --git a/test/flash/unit/app/admin/send-cashout-notification.spec.ts b/test/flash/unit/app/admin/send-cashout-notification.spec.ts new file mode 100644 index 000000000..3e0dbf5aa --- /dev/null +++ b/test/flash/unit/app/admin/send-cashout-notification.spec.ts @@ -0,0 +1,105 @@ + +import { GaloyNotificationCategories } from "@domain/notifications" +import { sendCashoutNotification } from "@app/admin/send-cashout-notification" +import { NotificationsService } from "@services/notifications" +import { AccountsRepository, UsersRepository } from "@services/mongoose" +import { checkedToDeviceToken } from "@domain/users" + +jest.mock("@services/notifications", () => ({ + NotificationsService: jest.fn(), +})) + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + UsersRepository: jest.fn(), +})) + +jest.mock("@domain/accounts", () => ({ + checkedToAccountUuid: (id: string) => id +})) + +jest.mock("@domain/users", () => ({ + checkedToDeviceToken: (token: string) => token as DeviceToken +})) + +describe("sendCashoutNotification", () => { + const accountId = "account-id" as AccountUuid + const title = "Test Title" + const body = "Test Body" + const amount = 100 + const currency = "USD" + + const mockAccount = { + uuid: accountId, + kratosUserId: "user-id", + notificationSettings: { + push: { + enabled: true, + disabledCategories: [], + } + } + } + + const mockUser = { + deviceTokens: ["override-token"] as DeviceToken[], + } + + const adminPushNotificationFilteredSend = jest.fn().mockResolvedValue(true) + + beforeEach(() => { + jest.clearAllMocks() + ; (NotificationsService as jest.Mock).mockReturnValue({ + adminPushNotificationFilteredSend, + }) + ; (AccountsRepository as jest.Mock).mockReturnValue({ + findByUuid: jest.fn().mockResolvedValue(mockAccount), + }) + ; (UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + }) + + it("sends notification to user device tokens", async () => { + const result = await sendCashoutNotification({ + accountId, + title, + body, + amount, + currency, + }) + + expect(result).toBe(true) + expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({ + deviceTokens: mockUser.deviceTokens, + title, + body, + data: { amount: "100", currency }, + notificationCategory: GaloyNotificationCategories.Payments, + notificationSettings: mockAccount.notificationSettings, + }) + }) + + it("sends notification to provided device tokens override", async () => { + const overrideTokens = ["override-token"] // paste given device token here + const result = await sendCashoutNotification({ + accountId, + title, + body, + amount, + currency, + deviceTokens: overrideTokens + }) + + expect(result).toBe(true) + expect(adminPushNotificationFilteredSend).toHaveBeenCalledWith({ + deviceTokens: [checkedToDeviceToken(overrideTokens[0])], + title, + body, + data: { amount: "100", currency }, + notificationCategory: GaloyNotificationCategories.Payments, + notificationSettings: mockAccount.notificationSettings, + }) + // Should NOT call UsersRepository if tokens are provided + expect(UsersRepository).not.toHaveBeenCalled() + }) +})