Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 37 additions & 0 deletions dev/bruno/Flash GraphQL API/admin/cashout-notification-send.bru
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
1 change: 1 addition & 0 deletions src/app/admin/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
62 changes: 62 additions & 0 deletions src/app/admin/send-cashout-notification.ts
Original file line number Diff line number Diff line change
@@ -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<true | ApplicationError> => {

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
}
2 changes: 2 additions & 0 deletions src/graphql/admin/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +25,7 @@ export const mutationFields = {
businessUpdateMapInfo: BusinessUpdateMapInfoMutation,
businessDeleteMapInfo: BusinessDeleteMapInfoMutation,
adminPushNotificationSend: AdminPushNotificationSendMutation,
cashoutNotificationSend: sendCashoutSettledNotification,
adminBroadcastSend: AdminBroadcastSendMutation,
},
}
Expand Down
58 changes: 58 additions & 0 deletions src/graphql/admin/root/mutation/cashout-notification-send.ts
Original file line number Diff line number Diff line change
@@ -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({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My PR is removing this function since we're changing the notification system. Unless you think it better to use this function, I'm thinking we should write sendNotificationToUser which queries the user's deviceTokens and then sends to each (which should already exist)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brh28 i think it is revelant part of the process because it is in that method we check the account Id and fetch the userId and device token. We can keep the same logic and just refactor the Notification domain from Galoy to our own

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we'll definitely need to query the deviceTokens as you're describing, though I'm curious why we might want to refactor the Notification domain. Afaik, we're not using any of these categories or this code

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GaloyNotificationCategories are defined in the notification domain layer. So if we want to have custom notification category we have to settle them in the notification layers.

I’m thinking the notification category is more revelant in the logs to identify various type of notifications we’ve sent to the user.

Actually I think we have 3-4 categories of notifications settled by Galoy, the default one is the balance notification. The cashout Notification category is currently missing and if we have to add it I think we have to do it in the notification domain layer.

accountId,
title,
body,
data: { amount: String(amount), currency },
notificationCategory
})

if (success instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(success)] }
}

return SUCCESS_RESPONSE

}
})

export default sendCashoutSettledNotification
8 changes: 8 additions & 0 deletions src/graphql/admin/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ input BusinessUpdateMapInfoInput {
username: Username!
}

input CashoutNotificationSendInput {
accountId: String!
amount: Float!
currency: String!
notificationCategory: NotificationCategory
}

type Coordinates {
latitude: Float!
longitude: Float!
Expand Down Expand Up @@ -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!
Expand Down
6 changes: 6 additions & 0 deletions src/services/notifications/push-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions test/flash/unit/app/admin/send-cashout-notification.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})