From 50e45e95417cc9b5354fac58e9245fc45c037a57 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Thu, 19 Mar 2026 08:31:00 -0500 Subject: [PATCH 1/2] feat(admin): Send notification by topic Notification types defined in yaml --- .env | 2 +- .../subscribe-device-tokens-to-broadcast.js | 104 ++++++++++++++++++ .../admin/notification-topics.bru | 21 ++++ .../admin/send-notification.bru | 36 ++++++ .../environments/flash-test.bru | 14 +++ .../Flash GraphQL API/notoken/folder.bru | 7 +- .../notoken/queries/supportedBanks.bru | 24 ++++ dev/config/base-config.yaml | 8 ++ src/app/admin/index.ts | 4 +- src/app/admin/send-admin-push-notification.ts | 88 +++++++-------- src/app/admin/send-broadcast-notification.ts | 43 -------- src/config/schema.ts | 6 + src/config/schema.types.d.ts | 1 + src/config/yaml.ts | 2 + src/domain/notifications/index.ts | 32 +++--- src/domain/notifications/index.types.d.ts | 4 +- src/graphql/admin/mutations.ts | 6 +- src/graphql/admin/queries.ts | 2 + .../root/mutation/admin-broadcast-send.ts | 58 ---------- .../mutation/admin-push-notification-send.ts | 68 ------------ .../admin/root/mutation/send-notification.ts | 69 ++++++++++++ .../admin/root/query/notification-topics.ts | 9 ++ src/graphql/admin/schema.graphql | 45 +++----- .../admin/types/scalar/broadcast-tag.ts | 31 ------ .../admin/types/scalar/notification-topic.ts | 31 ++++++ src/graphql/error.ts | 8 ++ ...24-subscribe-device-tokens-to-broadcast.ts | 56 ++++++++++ src/servers/graphql-admin-server.ts | 24 ++-- src/services/frappe/Roles.ts | 6 + src/services/notifications/index.ts | 22 ---- .../notifications/push-notifications.ts | 13 ++- .../push-notifications.types.d.ts | 2 + 32 files changed, 510 insertions(+), 336 deletions(-) create mode 100755 dev/bin/subscribe-device-tokens-to-broadcast.js create mode 100644 dev/bruno/Flash GraphQL API/admin/notification-topics.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/send-notification.bru create mode 100644 dev/bruno/Flash GraphQL API/environments/flash-test.bru create mode 100644 dev/bruno/Flash GraphQL API/notoken/queries/supportedBanks.bru delete mode 100644 src/app/admin/send-broadcast-notification.ts delete mode 100644 src/graphql/admin/root/mutation/admin-broadcast-send.ts delete mode 100644 src/graphql/admin/root/mutation/admin-push-notification-send.ts create mode 100644 src/graphql/admin/root/mutation/send-notification.ts create mode 100644 src/graphql/admin/root/query/notification-topics.ts delete mode 100644 src/graphql/admin/types/scalar/broadcast-tag.ts create mode 100644 src/graphql/admin/types/scalar/notification-topic.ts create mode 100644 src/migrations/20260317125624-subscribe-device-tokens-to-broadcast.ts create mode 100644 src/services/frappe/Roles.ts diff --git a/.env b/.env index cbce6645a..84c530aaf 100644 --- a/.env +++ b/.env @@ -58,7 +58,7 @@ export LND2_TYPE=offchain export LND1_NAME=lnd1 export LND2_NAME=lnd2 -export MONGODB_CON=mongodb://${DOCKER_HOST_IP}:27017/galoy +export MONGODB_CON=mongodb://localhost:27017/galoy export REDIS_0_DNS=${DOCKER_HOST_IP} export REDIS_0_PORT=6378 diff --git a/dev/bin/subscribe-device-tokens-to-broadcast.js b/dev/bin/subscribe-device-tokens-to-broadcast.js new file mode 100755 index 000000000..e0e6359fe --- /dev/null +++ b/dev/bin/subscribe-device-tokens-to-broadcast.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * One-off script: subscribes all device tokens stored in deviceTopics to their + * respective FCM topics via the Firebase Admin SDK. + * + * Run AFTER the migration `20260317125624-subscribe-device-tokens-to-broadcast` has + * been applied to populate the deviceTopics field. + * + * Required env vars: + * MONGODB_CON e.g. mongodb://localhost/galoy + * GOOGLE_APPLICATION_CREDENTIALS path to Firebase service account JSON + * FCM_TOPIC_PREFIX (optional) e.g. "test" → topic becomes "test-broadcast" + * omit on prod → topic is "broadcast" + */ + +const { MongoClient } = require("mongodb") +const admin = require("firebase-admin") + +const BATCH_SIZE = 1000 + +const MONGODB_CON = process.env.MONGODB_CON +if (!MONGODB_CON) { + console.error("Error: MONGODB_CON environment variable is required") + process.exit(1) +} + +if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + console.error("Error: GOOGLE_APPLICATION_CREDENTIALS environment variable is required") + process.exit(1) +} + +admin.initializeApp({ credential: admin.credential.applicationDefault() }) +const messaging = admin.messaging() + +async function subscribeInBatches(tokens, topic) { + let successCount = 0 + let failureCount = 0 + + for (let i = 0; i < tokens.length; i += BATCH_SIZE) { + const batch = tokens.slice(i, i + BATCH_SIZE) + const batchNum = Math.floor(i / BATCH_SIZE) + 1 + console.log( + `Subscribing batch ${batchNum} (tokens ${i + 1}–${i + batch.length}) to topic "${topic}"`, + ) + + const response = await messaging.subscribeToTopic(batch, topic) + successCount += response.successCount + failureCount += response.failureCount + + if (response.errors.length > 0) { + response.errors.forEach(({ index, error }) => { + console.warn(` Token[${index}] failed: ${error.message}`) + }) + } + } + + return { successCount, failureCount } +} + +async function main() { + const client = new MongoClient(MONGODB_CON) + + try { + await client.connect() + const db = client.db() + + const users = await db + .collection("users") + .find( + { deviceTopics: { $exists: true } }, + { projection: { _id: 0, deviceTopics: 1 } }, + ) + .toArray() + + if (users.length === 0) { + console.log("No users with deviceTopics found — run the migration first") + return + } + + // Group tokens by topic + const tokensByTopic = {} + for (const user of users) { + for (const [token, topics] of Object.entries(user.deviceTopics)) { + for (const topic of topics) { + if (!tokensByTopic[topic]) tokensByTopic[topic] = [] + tokensByTopic[topic].push(token) + } + } + } + + for (const [topic, tokens] of Object.entries(tokensByTopic)) { + console.log(`\nSubscribing ${tokens.length} tokens to topic "${topic}"`) + const { successCount, failureCount } = await subscribeInBatches(tokens, topic) + console.log(`Done — success: ${successCount}, failures: ${failureCount}`) + } + } finally { + await client.close() + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/dev/bruno/Flash GraphQL API/admin/notification-topics.bru b/dev/bruno/Flash GraphQL API/admin/notification-topics.bru new file mode 100644 index 000000000..356495f6c --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/notification-topics.bru @@ -0,0 +1,21 @@ +meta { + name: notification-topics + type: graphql + seq: 3 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + query { + notificationTopics + } +} diff --git a/dev/bruno/Flash GraphQL API/admin/send-notification.bru b/dev/bruno/Flash GraphQL API/admin/send-notification.bru new file mode 100644 index 000000000..6fbd7d5e9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/send-notification.bru @@ -0,0 +1,36 @@ +meta { + name: send-notification + type: graphql + seq: 2 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation SendNotification($input: SendNotificationInput!) { + sendNotification(input: $input) { + errors { + message + } + success + } + } +} + +body:graphql:vars { + { + "input": { + "topic": "brh28-ATTENTION", + "title": "Hello", + "body": "This is a notification" + } + } +} diff --git a/dev/bruno/Flash GraphQL API/environments/flash-test.bru b/dev/bruno/Flash GraphQL API/environments/flash-test.bru new file mode 100644 index 000000000..575dc7e84 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/environments/flash-test.bru @@ -0,0 +1,14 @@ +vars { + main_protocol: https + main_domain: api.test.flashapp.me + main_port: 4002 + ~admin_url: http://localhost:4001/graphql + ~admin_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhZG1pbiIsInJvbGVzIjpbIkFjY291bnRzIE1hbmFnZXIiXX0.UOmQR2K6RdS1FVvQbjvSQfoQ-VsTC6Y7x2YAXZImdsA + currency: BTC + ~phone: +1301 + ~code: 000000 + token: + walletId: + walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 + graphqlUrl: https://api.test.flashapp.me/graphql +} diff --git a/dev/bruno/Flash GraphQL API/notoken/folder.bru b/dev/bruno/Flash GraphQL API/notoken/folder.bru index f74a6a0e8..d6b6f9236 100644 --- a/dev/bruno/Flash GraphQL API/notoken/folder.bru +++ b/dev/bruno/Flash GraphQL API/notoken/folder.bru @@ -1,6 +1,11 @@ meta { name: notoken - seq: 1 +} + +headers { + Accept: */* + Connection: keep-alive + Accept-Encoding: gzip, deflate, br } auth { diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/supportedBanks.bru b/dev/bruno/Flash GraphQL API/notoken/queries/supportedBanks.bru new file mode 100644 index 000000000..aaf1fcff5 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/notoken/queries/supportedBanks.bru @@ -0,0 +1,24 @@ +meta { + name: supportedBanks + type: graphql + seq: 8 +} + +post { + url: {{graphqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + query { + supportedBanks { + name + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index f1961e86a..fe4370dd6 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -51,3 +51,11 @@ frappe: sendgrid: apiKey: "" + +# FCM topic names for push notifications. +# Replace "dev" with a unique identifier to avoid accidentally sending to other environments. +notificationTopics: + - "dev-EMERGENCY" + - "dev-ATTENTION" + - "dev-INFO" + - "dev-MARKETING" diff --git a/src/app/admin/index.ts b/src/app/admin/index.ts index 95a73b1dd..7e086d533 100644 --- a/src/app/admin/index.ts +++ b/src/app/admin/index.ts @@ -1,6 +1,6 @@ export * from "./update-user-phone" -export * from "./send-admin-push-notification" -export * from "./send-broadcast-notification" +// export * from "./send-admin-push-notification" +// export * from "./send-broadcast-notification" import { checkedToAccountUuid, checkedToUsername } from "@domain/accounts" import { IdentityRepository } from "@services/kratos" diff --git a/src/app/admin/send-admin-push-notification.ts b/src/app/admin/send-admin-push-notification.ts index 569421442..7e70a7fee 100644 --- a/src/app/admin/send-admin-push-notification.ts +++ b/src/app/admin/send-admin-push-notification.ts @@ -1,51 +1,51 @@ -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" +// 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" -export const sendAdminPushNotification = async ({ - accountId: accountIdRaw, - title, - body, - data, - notificationCategory, -}: { - accountId: string - title: string - body: string - data?: { [key: string]: string } - notificationCategory?: string -}): Promise => { - const checkedNotificationCategory = notificationCategory - ? checkedToNotificationCategory(notificationCategory) - : GaloyNotificationCategories.AdminPushNotification +// export const sendAdminPushNotification = async ({ +// accountId: accountIdRaw, +// 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 +// if (checkedNotificationCategory instanceof Error) return checkedNotificationCategory - const accountId = checkedToAccountUuid(accountIdRaw) - if (accountId instanceof Error) return accountId +// 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 +// const accountsRepo = AccountsRepository() +// const account = await accountsRepo.findByUuid(accountId) +// if (account instanceof Error) return account +// const kratosUserId = account.kratosUserId - const usersRepo = UsersRepository() - const user = await usersRepo.findById(kratosUserId) - if (user instanceof Error) return user +// const usersRepo = UsersRepository() +// const user = await usersRepo.findById(kratosUserId) +// if (user instanceof Error) return user - const success = await NotificationsService().adminPushNotificationFilteredSend({ - deviceTokens: user.deviceTokens, - title, - body, - data, - notificationCategory: checkedNotificationCategory, - notificationSettings: account.notificationSettings, - }) +// const success = await NotificationsService().adminPushNotificationFilteredSend({ +// deviceTokens: user.deviceTokens, +// title, +// body, +// data, +// notificationCategory: checkedNotificationCategory, +// notificationSettings: account.notificationSettings, +// }) - return success -} +// return success +// } diff --git a/src/app/admin/send-broadcast-notification.ts b/src/app/admin/send-broadcast-notification.ts deleted file mode 100644 index 8a14bc659..000000000 --- a/src/app/admin/send-broadcast-notification.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { checkedToBroadcastTag } from "@domain/notifications" - -import { NotificationsService } from "@services/notifications" -import { User } from "@services/mongoose/schema" - -export const sendBroadcastNotification = async ({ - title, - body, - tag, -}: { - title: string - body: string - tag: string -}): Promise => { - // Validate broadcast tag - const broadcastTag = checkedToBroadcastTag(tag) - if (broadcastTag instanceof Error) return broadcastTag - - // Fetch all users with device tokens - const users = await User.find( - { deviceTokens: { $exists: true, $not: { $size: 0 } } }, - { deviceTokens: 1 }, - ).lean() - - if (!users || users.length === 0) return true - - // Collect all device tokens from all users - const allDeviceTokens: DeviceToken[] = users.flatMap( - (user) => user.deviceTokens as DeviceToken[], - ) - - if (allDeviceTokens.length === 0) return true - - // Send broadcast notification with tag in data - const result = await NotificationsService().sendBroadcast({ - deviceTokens: allDeviceTokens, - title, - body, - data: { tag: broadcastTag }, - }) - - return result -} diff --git a/src/config/schema.ts b/src/config/schema.ts index fd573cea4..1373b4a96 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -673,6 +673,11 @@ export const configSchema = { type: "object", required: ["apiKey"], }, + notificationTopics: { + type: "array", + items: { type: "string" }, + uniqueItems: true, + }, }, required: [ "lightningAddressDomain", @@ -704,6 +709,7 @@ export const configSchema = { "exchangeRates", "cashout", "ibex", + "notificationTopics", ], additionalProperties: false, } as const diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 4ae4daa90..fc2e0d3cb 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -190,6 +190,7 @@ type YamlSchema = { } sendgrid: SendGridConfig frappe: FrappeConfig + notificationTopics: string[] } type FrappeCredentials = { diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 98b9c5ab3..8c01772ea 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -269,6 +269,8 @@ export const getTestAccounts = (config = yamlConfig): TestAccount[] => export const getCronConfig = (config = yamlConfig): CronConfig => config.cronConfig +export const getNotificationTopics = (config = yamlConfig): string[] => config.notificationTopics + export const getCaptcha = (config = yamlConfig): CaptchaConfig => config.captcha export const getRewardsConfig = (): RewardsConfig => { diff --git a/src/domain/notifications/index.ts b/src/domain/notifications/index.ts index 238623aa9..8ad2df69b 100644 --- a/src/domain/notifications/index.ts +++ b/src/domain/notifications/index.ts @@ -1,4 +1,5 @@ import { InvalidPushNotificationSettingError as InvalidNotificationSettingsError } from "./errors" +import { getNotificationTopics } from "@config" export * from "./errors" @@ -21,13 +22,6 @@ export const GaloyNotificationCategories = { AdminPushNotification: "AdminPushNotification" as NotificationCategory, } as const -export const BroadcastTag = { - EMERGENCY: "EMERGENCY", - ATTENTION: "ATTENTION", - INFO: "INFO", - MARKETING: "MARKETING", -} as const - export const checkedToNotificationCategory = ( notificationCategory: string, ): NotificationCategory | ValidationError => { @@ -39,18 +33,6 @@ export const checkedToNotificationCategory = ( return notificationCategory as NotificationCategory } -export const checkedToBroadcastTag = ( - tag: string, -): BroadcastTag | ValidationError => { - const validTags = Object.values(BroadcastTag) - if (!validTags.includes(tag as BroadcastTag)) { - return new InvalidNotificationSettingsError( - `Invalid broadcast tag. Must be one of: ${validTags.join(", ")}`, - ) - } - return tag as BroadcastTag -} - export const enableNotificationChannel = ({ notificationSettings, notificationChannel, @@ -191,3 +173,15 @@ export const shouldSendNotification = ({ return false } + +export const checkedToNotificationTopic = ( + t: string, +): NotificationTopic | ValidationError => { + const topics = getNotificationTopics() + if (!topics.includes(t)) { + return new InvalidNotificationSettingsError( + `Invalid topic. Must be one of: ${topics.join(", ")}`, + ) + } + return t as unknown as NotificationTopic +} diff --git a/src/domain/notifications/index.types.d.ts b/src/domain/notifications/index.types.d.ts index 68ae40f9f..9439f7df3 100644 --- a/src/domain/notifications/index.types.d.ts +++ b/src/domain/notifications/index.types.d.ts @@ -87,14 +87,12 @@ interface INotificationsService { adminPushNotificationFilteredSend( args: SendFilteredPushNotificationArgs, ): Promise - sendBroadcast(args: SendBroadcastArgs): Promise } type NotificationChannel = (typeof import("./index").NotificationChannel)[keyof typeof import("./index").NotificationChannel] -type BroadcastTag = - (typeof import("./index").BroadcastTag)[keyof typeof import("./index").BroadcastTag] +type NotificationTopic = string & { readonly brand: unique symbol } type NotificationSettings = Record diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 3d5d5547a..6df90eb35 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -6,8 +6,7 @@ import BusinessUpdateMapInfoMutation from "@graphql/admin/root/mutation/business 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 SendNotificationMutation from "./root/mutation/send-notification" import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" @@ -23,8 +22,7 @@ export const mutationFields = { merchantMapDelete: MerchantMapDeleteMutation, businessUpdateMapInfo: BusinessUpdateMapInfoMutation, businessDeleteMapInfo: BusinessDeleteMapInfoMutation, - adminPushNotificationSend: AdminPushNotificationSendMutation, - adminBroadcastSend: AdminBroadcastSendMutation, + sendNotification: SendNotificationMutation, }, } diff --git a/src/graphql/admin/queries.ts b/src/graphql/admin/queries.ts index 1e1f3744d..f18b8836a 100644 --- a/src/graphql/admin/queries.ts +++ b/src/graphql/admin/queries.ts @@ -14,6 +14,7 @@ import WalletQuery from "./root/query/wallet" import AccountDetailsByAccountId from "./root/query/account-details-by-account-id" import MerchantsPendingApprovalQuery from "./root/query/merchants-pending-approval-listing" import IdDocumentReadUrlQuery from "./root/query/id-document-read-url" +import NotificationTopicsQuery from "./root/query/notification-topics" export const queryFields = { unauthed: {}, @@ -32,6 +33,7 @@ export const queryFields = { wallet: WalletQuery, merchantsPendingApproval: MerchantsPendingApprovalQuery, idDocumentReadUrl: IdDocumentReadUrlQuery, + notificationTopics: NotificationTopicsQuery, }, } diff --git a/src/graphql/admin/root/mutation/admin-broadcast-send.ts b/src/graphql/admin/root/mutation/admin-broadcast-send.ts deleted file mode 100644 index 50f6e7b56..000000000 --- a/src/graphql/admin/root/mutation/admin-broadcast-send.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { GT } from "@graphql/index" - -import AdminBroadcastSendPayload from "@graphql/admin/types/payload/admin-broadcast-send" -import BroadcastTag from "@graphql/admin/types/scalar/broadcast-tag" -import { Admin } from "@app" -import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" -import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload" - -const AdminBroadcastSendInput = GT.Input({ - name: "AdminBroadcastSendInput", - fields: () => ({ - title: { - type: GT.NonNull(GT.String), - }, - body: { - type: GT.NonNull(GT.String), - }, - tag: { - type: GT.NonNull(BroadcastTag), - }, - }), -}) - -const AdminBroadcastSendMutation = GT.Field< - null, - GraphQLAdminContext, - { - input: { - title: string - body: string - tag: string - } - } ->({ - extensions: { - complexity: 120, - }, - type: GT.NonNull(AdminBroadcastSendPayload), - args: { - input: { type: GT.NonNull(AdminBroadcastSendInput) }, - }, - resolve: async (_, args) => { - const { title, body, tag } = args.input - - const success = await Admin.sendBroadcastNotification({ - title, - body, - tag, - }) - - if (success instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(success)] } - } - return SUCCESS_RESPONSE - }, -}) - -export default AdminBroadcastSendMutation diff --git a/src/graphql/admin/root/mutation/admin-push-notification-send.ts b/src/graphql/admin/root/mutation/admin-push-notification-send.ts deleted file mode 100644 index 12efc4fab..000000000 --- a/src/graphql/admin/root/mutation/admin-push-notification-send.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload" -import NotificationCategory from "@graphql/shared/types/scalar/notification-category" - -const AdminPushNotificationSendInput = GT.Input({ - name: "AdminPushNotificationSendInput", - fields: () => ({ - accountId: { - type: GT.NonNull(GT.String), - }, - title: { - type: GT.NonNull(GT.String), - }, - body: { - type: GT.NonNull(GT.String), - }, - data: { - type: GT.Scalar(Object), - }, - notificationCategory: { - type: NotificationCategory, - }, - }), -}) - -const AdminPushNotificationSendMutation = GT.Field< - null, - GraphQLAdminContext, - { - input: { - accountId: string - title: string - body: string - data?: { [key: string]: string } - notificationCategory?: string - } - } ->({ - extensions: { - complexity: 120, - }, - type: GT.NonNull(AdminPushNotificationSendPayload), - args: { - input: { type: GT.NonNull(AdminPushNotificationSendInput) }, - }, - resolve: async (_, args) => { - const { accountId, body, title, data, notificationCategory } = args.input - - const success = await Admin.sendAdminPushNotification({ - accountId, - title, - body, - data, - notificationCategory, - }) - - if (success instanceof Error) { - return { errors: [mapAndParseErrorForGqlResponse(success)] } - } - return SUCCESS_RESPONSE - }, -}) - -export default AdminPushNotificationSendMutation diff --git a/src/graphql/admin/root/mutation/send-notification.ts b/src/graphql/admin/root/mutation/send-notification.ts new file mode 100644 index 000000000..83f13068e --- /dev/null +++ b/src/graphql/admin/root/mutation/send-notification.ts @@ -0,0 +1,69 @@ +import { GT } from "@graphql/index" +import { apolloErrorResponse } from "@graphql/error-map" +import { PushNotificationsService } from "@services/notifications/push-notifications" +import { FirebaseError } from "@graphql/error" +import IError from "@graphql/shared/types/abstract/error" +import NotificationTopicScalar from "@graphql/admin/types/scalar/notification-topic" +import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload" + +const SendNotificationInput = GT.Input({ + name: "SendNotificationInput", + fields: () => ({ + topic: { + type: GT.NonNull(NotificationTopicScalar), + }, + title: { + type: GT.NonNull(GT.String), + }, + body: { + type: GT.NonNull(GT.String), + }, + }), +}) + +const SendNotificationPayload = GT.Object({ + name: "SendNotificationPayload", + fields: () => ({ + errors: { + type: GT.List(IError), + }, + success: { + type: GT.Boolean, + }, + }), +}) + + +const SendNotificationMutation = GT.Field< + null, + GraphQLAdminContext, + { + input: { + topic: NotificationTopic + title: string + body: string + } + } +>({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(SendNotificationPayload), + args: { + input: { type: GT.NonNull(SendNotificationInput) }, + }, + resolve: async (_, args) => { + const { topic, title, body } = args.input + + const firebase = PushNotificationsService() + const res = await firebase.send({ + topic, + notification: { title, body }, + }) + if (res instanceof Error) return apolloErrorResponse(new FirebaseError({ message: "Failed to send push notification(s)", error: res })) + + return SUCCESS_RESPONSE + }, +}) + +export default SendNotificationMutation diff --git a/src/graphql/admin/root/query/notification-topics.ts b/src/graphql/admin/root/query/notification-topics.ts new file mode 100644 index 000000000..1b62fcf7e --- /dev/null +++ b/src/graphql/admin/root/query/notification-topics.ts @@ -0,0 +1,9 @@ +import { GT } from "@graphql/index" +import { getNotificationTopics } from "@config" + +const NotificationTopicsQuery = GT.Field({ + type: GT.NonNullList(GT.String), + resolve: () => getNotificationTopics(), +}) + +export default NotificationTopicsQuery diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 85b9f3043..03e986fa2 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -30,30 +30,6 @@ input AccountUpdateStatusInput { uid: ID! } -input AdminBroadcastSendInput { - body: String! - tag: BroadcastTag! - title: String! -} - -type AdminBroadcastSendPayload { - errors: [Error!]! - success: Boolean -} - -input AdminPushNotificationSendInput { - accountId: String! - body: String! - data: Object - notificationCategory: NotificationCategory - title: String! -} - -type AdminPushNotificationSendPayload { - errors: [Error!]! - success: Boolean -} - """ Accounts are core to the Galoy architecture. they have users, and own wallets """ @@ -133,8 +109,6 @@ type BTCWallet implements Wallet { walletCurrency: WalletCurrency! } -scalar BroadcastTag - input BusinessDeleteMapInfoInput { username: Username! } @@ -280,18 +254,15 @@ type MerchantPayload { type Mutation { accountUpdateLevel(input: AccountUpdateLevelInput!): AccountDetailPayload! accountUpdateStatus(input: AccountUpdateStatusInput!): AccountDetailPayload! - adminBroadcastSend(input: AdminBroadcastSendInput!): AdminBroadcastSendPayload! - adminPushNotificationSend(input: AdminPushNotificationSendInput!): AdminPushNotificationSendPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! + sendNotification(input: SendNotificationInput!): SendNotificationPayload! userUpdatePhone(input: UserUpdatePhoneInput!): AccountDetailPayload! } -scalar NotificationCategory - -scalar Object +scalar NotificationTopic """An address for an on-chain bitcoin destination""" scalar OnChainAddress @@ -348,6 +319,7 @@ type Query { lightningPayment(hash: PaymentHash!): LightningPayment! listWalletIds(walletCurrency: WalletCurrency!): [WalletId!]! merchantsPendingApproval: [Merchant!]! + notificationTopics: [String!]! transactionById(id: ID!): Transaction transactionDetailsById(id: ID!): TransactionDetails transactionsByHash(hash: PaymentHash!): [Transaction] @@ -362,6 +334,17 @@ scalar SafeInt """(Positive) Satoshi amount""" scalar SatAmount +input SendNotificationInput { + body: String! + title: String! + topic: NotificationTopic! +} + +type SendNotificationPayload { + errors: [Error] + success: Boolean +} + union SettlementVia = SettlementViaIntraLedger | SettlementViaLn | SettlementViaOnChain type SettlementViaIntraLedger { diff --git a/src/graphql/admin/types/scalar/broadcast-tag.ts b/src/graphql/admin/types/scalar/broadcast-tag.ts deleted file mode 100644 index 23bbfc92c..000000000 --- a/src/graphql/admin/types/scalar/broadcast-tag.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { InputValidationError } from "@graphql/error" -import { GT } from "@graphql/index" -import { checkedToBroadcastTag } from "@domain/notifications" - -const BroadcastTag = GT.Scalar({ - name: "BroadcastTag", - parseValue(value) { - if (typeof value !== "string") { - return new InputValidationError({ - message: "Invalid type for BroadcastTag", - }) - } - return validBroadcastTag(value) - }, - parseLiteral(ast) { - if (ast.kind === GT.Kind.STRING) { - return validBroadcastTag(ast.value) - } - return new InputValidationError({ message: "Invalid type for BroadcastTag" }) - }, -}) - -function validBroadcastTag(value: string): BroadcastTag | InputValidationError { - const checkedTag = checkedToBroadcastTag(value) - if (checkedTag instanceof Error) { - return new InputValidationError({ message: checkedTag.message }) - } - return checkedTag -} - -export default BroadcastTag diff --git a/src/graphql/admin/types/scalar/notification-topic.ts b/src/graphql/admin/types/scalar/notification-topic.ts new file mode 100644 index 000000000..9b31d5b6c --- /dev/null +++ b/src/graphql/admin/types/scalar/notification-topic.ts @@ -0,0 +1,31 @@ +import { InputValidationError } from "@graphql/error" +import { GT } from "@graphql/index" +import { checkedToNotificationTopic } from "@domain/notifications" + +const NotificationTopic = GT.Scalar({ + name: "NotificationTopic", + parseValue(value) { + if (typeof value !== "string") { + return new InputValidationError({ + message: "Invalid type for NotificationTopic", + }) + } + return validNotificationTopic(value) + }, + parseLiteral(ast) { + if (ast.kind === GT.Kind.STRING) { + return validNotificationTopic(ast.value) + } + return new InputValidationError({ message: "Invalid type for NotificationTopic" }) + }, +}) + +function validNotificationTopic(value: string): NotificationTopic | InputValidationError { + const checkedTopic = checkedToNotificationTopic(value) + if (checkedTopic instanceof Error) { + return new InputValidationError({ message: checkedTopic.message }) + } + return checkedTopic +} + +export default NotificationTopic diff --git a/src/graphql/error.ts b/src/graphql/error.ts index 3a9a6d893..a496db1ec 100644 --- a/src/graphql/error.ts +++ b/src/graphql/error.ts @@ -472,3 +472,11 @@ export class InternalServerError extends CustomApolloError { super({ level: "error", code: "INTERNAL_SERVER_ERROR", forwardToClient: false, ...errData }) } } + +// Admin API error +export class FirebaseError extends CustomApolloError { + constructor(errData: CustomApolloErrorData) { + super({ level: "error", code: "FIREBASE_ERROR", forwardToClient: true, ...errData }) + } +} + diff --git a/src/migrations/20260317125624-subscribe-device-tokens-to-broadcast.ts b/src/migrations/20260317125624-subscribe-device-tokens-to-broadcast.ts new file mode 100644 index 000000000..86fb88892 --- /dev/null +++ b/src/migrations/20260317125624-subscribe-device-tokens-to-broadcast.ts @@ -0,0 +1,56 @@ +/* eslint @typescript-eslint/ban-ts-comment: "off" */ +// @ts-nocheck +/* eslint @typescript-eslint/no-var-requires: "off" */ + +// Topics vary be environment, must be passed in to match yaml config +const topicsEnv = process.env.NOTIFICATION_TOPICS +if (!topicsEnv) throw new Error("NOTIFICATION_TOPICS env var is required (comma-separated list of FCM topic names)") +const topics = topicsEnv.split(",").map(t => t.trim()).filter(Boolean) + +module.exports = { + async up(db) { + console.log(`Begin migration: write deviceTopics field for topics "${topics.join(", ")}" to all users with device tokens`) + + const users = await db + .collection("users") + .find( + { deviceTokens: { $exists: true, $not: { $size: 0 } } }, + { projection: { _id: 1, deviceTokens: 1 } }, + ) + .toArray() + + if (users.length === 0) { + console.log("No users with device tokens found — nothing to do") + return + } + + console.log(`Found ${users.length} users with device tokens`) + + const bulkOps = users.map((user) => { + const deviceTopics = {} + for (const token of user.deviceTokens ?? []) { + deviceTopics[token] = topics + } + return { + updateOne: { + filter: { _id: user._id }, + update: { $set: { deviceTopics } }, + }, + } + }) + + await db.collection("users").bulkWrite(bulkOps) + + console.log(`Migration complete: ${bulkOps.length} users updated with deviceTopics`) + }, + + async down(db) { + console.log("Begin rollback: remove deviceTopics field from all users") + + await db + .collection("users") + .updateMany({ deviceTopics: { $exists: true } }, { $unset: { deviceTopics: 1 } }) + + console.log("Rollback complete: deviceTopics removed from all users") + }, +} diff --git a/src/servers/graphql-admin-server.ts b/src/servers/graphql-admin-server.ts index 34eeae6f4..1a220b07c 100644 --- a/src/servers/graphql-admin-server.ts +++ b/src/servers/graphql-admin-server.ts @@ -1,6 +1,6 @@ import { applyMiddleware } from "graphql-middleware" -import { and, rule, shield } from "graphql-shield" -import { Rule, RuleAnd } from "graphql-shield/typings/rules" +import { and, or, rule, shield } from "graphql-shield" +import { Rule, RuleAnd, RuleOr } from "graphql-shield/typings/rules" import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { adminMutationFields, adminQueryFields, gqlAdminSchema } from "@graphql/admin" @@ -21,6 +21,7 @@ import healthzHandler from "./middlewares/healthz" import { idempotencyMiddleware } from "./middlewares/idempotency" import requestIp from "request-ip" import jwt from 'jsonwebtoken' +import { ErpNextRole, ErpNextRoles } from "@services/frappe/Roles" const graphqlLogger = baseLogger.child({ module: "graphql" }) @@ -42,12 +43,12 @@ function parseAuthHeader(authHeader: string | undefined): JWTPayload { } } -export const hasAdminUserRole = rule({ cache: "contextual" })(( +export const hasRole = (role: ErpNextRole) => rule({ cache: "contextual" })(( parent, args, ctx: GraphQLAdminContext, ) => { - return ctx.user.roles.includes("Accounts Manager") ? true : new AuthorizationError({ logger: graphqlLogger }) + return ctx.user.roles.includes(role) ? true : new AuthorizationError({ logger: graphqlLogger }) }) // // const ipString = UNSECURE_IP_FROM_REQUEST_OBJECT @@ -216,14 +217,21 @@ const startAdminServer = async ({ } export async function startApolloServerForAdminSchema() { - const authedQueryFields: { [key: string]: Rule } = {} + const defaultRule = or(hasRole(ErpNextRoles.SystemManager), hasRole(ErpNextRoles.AccountsManager)) + + const authedQueryFields: { [key: string]: RuleOr } = {} for (const key of Object.keys(adminQueryFields.authed)) { - authedQueryFields[key] = hasAdminUserRole + authedQueryFields[key] = defaultRule + } + + + const mutationRoleOverrides: { [key: string]: Rule } = { + // sendNotification: hasRole(ErpNextRoles.SystemManager) } - const authedMutationFields: { [key: string]: Rule } = {} + const authedMutationFields: { [key: string]: Rule | RuleOr } = {} for (const key of Object.keys(adminMutationFields.authed)) { - authedMutationFields[key] = hasAdminUserRole + authedMutationFields[key] = mutationRoleOverrides[key] ?? defaultRule } const permissions = shield( diff --git a/src/services/frappe/Roles.ts b/src/services/frappe/Roles.ts new file mode 100644 index 000000000..b2d2938ef --- /dev/null +++ b/src/services/frappe/Roles.ts @@ -0,0 +1,6 @@ +export const ErpNextRoles = { + AccountsManager: "Accounts Manager", + SystemManager: "System Manager", +} as const + +export type ErpNextRole = (typeof ErpNextRoles)[keyof typeof ErpNextRoles] diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index 140e4c024..e187c662a 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -425,27 +425,6 @@ export const NotificationsService = (): INotificationsService => { } } - const sendBroadcast = async ({ - title, - body, - data, - deviceTokens, - }: SendBroadcastArgs): Promise => { - const hasDeviceTokens = deviceTokens && deviceTokens.length > 0 - if (!hasDeviceTokens) return true - - try { - return pushNotification.sendNotification({ - deviceTokens, - title, - body, - data, - }) - } catch (err) { - return handleCommonNotificationErrors(err) - } - } - // trace everything except price update because it runs every 30 seconds return { priceUpdate, @@ -460,7 +439,6 @@ export const NotificationsService = (): INotificationsService => { sendBalance, adminPushNotificationSend, adminPushNotificationFilteredSend, - sendBroadcast, }, }), } diff --git a/src/services/notifications/push-notifications.ts b/src/services/notifications/push-notifications.ts index 0835b3e1b..059934e46 100644 --- a/src/services/notifications/push-notifications.ts +++ b/src/services/notifications/push-notifications.ts @@ -19,6 +19,7 @@ import { } from "@services/tracing" import { messaging } from "./firebase" import { FirebaseError } from "firebase-admin" +import { Message } from "firebase-admin/lib/messaging/messaging-api" const logger = baseLogger.child({ module: "notifications" }) @@ -73,7 +74,17 @@ const sendToDevice = async ( } } +// Wraps the Firebase messaging service export const PushNotificationsService = (): IPushNotificationsService => { + const send = async (message: Message): Promise => { + if (!messaging) { + baseLogger.error("Firebase messaging module not loaded") + return new NotificationsServiceError("Firebase messaging module not loaded") + } + + return await messaging.send(message) + } + const sendNotification = async ({ deviceTokens, title, @@ -134,7 +145,7 @@ export const PushNotificationsService = (): IPushNotificationsService => { } } - return { sendNotification, sendFilteredNotification } + return { send, sendNotification, sendFilteredNotification } } export const handleCommonNotificationErrors = (err: Error | string | unknown) => { diff --git a/src/services/notifications/push-notifications.types.d.ts b/src/services/notifications/push-notifications.types.d.ts index c8a8dd928..f3bf393fe 100644 --- a/src/services/notifications/push-notifications.types.d.ts +++ b/src/services/notifications/push-notifications.types.d.ts @@ -18,6 +18,8 @@ type SendFilteredPushNotificationStatus = (typeof import("./push-notifications").SendFilteredPushNotificationStatus)[keyof typeof import("./push-notifications").SendFilteredPushNotificationStatus] interface IPushNotificationsService { + send(message: Message): Promise + sendNotification({ deviceTokens, title, From b806f267e997a388f4f1e8c1e4b297653eba668b Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Fri, 20 Mar 2026 15:23:12 -0500 Subject: [PATCH 2/2] address @island's review comments --- src/app/admin/send-admin-push-notification.ts | 51 ------------------- src/config/schema.ts | 2 +- .../admin/root/mutation/send-notification.ts | 4 +- src/graphql/error.ts | 2 +- 4 files changed, 4 insertions(+), 55 deletions(-) delete mode 100644 src/app/admin/send-admin-push-notification.ts diff --git a/src/app/admin/send-admin-push-notification.ts b/src/app/admin/send-admin-push-notification.ts deleted file mode 100644 index 7e70a7fee..000000000 --- a/src/app/admin/send-admin-push-notification.ts +++ /dev/null @@ -1,51 +0,0 @@ -// 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" - -// export const sendAdminPushNotification = async ({ -// accountId: accountIdRaw, -// 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 - -// const accountsRepo = AccountsRepository() -// const account = await accountsRepo.findByUuid(accountId) -// if (account instanceof Error) return account -// const kratosUserId = account.kratosUserId - -// const usersRepo = UsersRepository() -// const user = await usersRepo.findById(kratosUserId) -// if (user instanceof Error) return user - -// const success = await NotificationsService().adminPushNotificationFilteredSend({ -// deviceTokens: user.deviceTokens, -// title, -// body, -// data, -// notificationCategory: checkedNotificationCategory, -// notificationSettings: account.notificationSettings, -// }) - -// return success -// } diff --git a/src/config/schema.ts b/src/config/schema.ts index 1373b4a96..a0569e06f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -677,6 +677,7 @@ export const configSchema = { type: "array", items: { type: "string" }, uniqueItems: true, + default: [], }, }, required: [ @@ -709,7 +710,6 @@ export const configSchema = { "exchangeRates", "cashout", "ibex", - "notificationTopics", ], additionalProperties: false, } as const diff --git a/src/graphql/admin/root/mutation/send-notification.ts b/src/graphql/admin/root/mutation/send-notification.ts index 83f13068e..66dd8323d 100644 --- a/src/graphql/admin/root/mutation/send-notification.ts +++ b/src/graphql/admin/root/mutation/send-notification.ts @@ -1,7 +1,7 @@ import { GT } from "@graphql/index" import { apolloErrorResponse } from "@graphql/error-map" import { PushNotificationsService } from "@services/notifications/push-notifications" -import { FirebaseError } from "@graphql/error" +import { PushNotificationError } from "@graphql/error" import IError from "@graphql/shared/types/abstract/error" import NotificationTopicScalar from "@graphql/admin/types/scalar/notification-topic" import { SUCCESS_RESPONSE } from "@graphql/shared/types/payload/success-payload" @@ -60,7 +60,7 @@ const SendNotificationMutation = GT.Field< topic, notification: { title, body }, }) - if (res instanceof Error) return apolloErrorResponse(new FirebaseError({ message: "Failed to send push notification(s)", error: res })) + if (res instanceof Error) return apolloErrorResponse(new PushNotificationError({ message: "Failed to send push notification(s)", error: res })) return SUCCESS_RESPONSE }, diff --git a/src/graphql/error.ts b/src/graphql/error.ts index a496db1ec..aeac77afe 100644 --- a/src/graphql/error.ts +++ b/src/graphql/error.ts @@ -474,7 +474,7 @@ export class InternalServerError extends CustomApolloError { } // Admin API error -export class FirebaseError extends CustomApolloError { +export class PushNotificationError extends CustomApolloError { constructor(errData: CustomApolloErrorData) { super({ level: "error", code: "FIREBASE_ERROR", forwardToClient: true, ...errData }) }