-
Notifications
You must be signed in to change notification settings - Fork 5
Feat : Cashout notification #297
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } | ||
| } | ||
| } |
| 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 | ||
| } |
| 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({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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() | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.