Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/app/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./update-user-phone"
// export * from "./send-admin-push-notification"
// export * from "./send-broadcast-notification"
export * from "./send-cashout-notification"
export * from "./send-user-notification"
export * from "./invite"

// Re-export query functions from invite module for admin GraphQL compatibility
Expand Down
131 changes: 131 additions & 0 deletions src/app/admin/send-user-notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { removeDeviceTokens } from "@app/users/remove-device-tokens"
import {
AllDeviceTokensStaleNotificationsServiceError,
DeviceTokensNotRegisteredNotificationsServiceError,
FlashNotificationCategories,
InvalidDeviceNotificationsServiceError,
NoDeviceAcceptedPushNotificationsServiceError,
RecipientDisabledNotificationsServiceError,
} from "@domain/notifications"
import { baseLogger } from "@services/logger"
import { AccountsRepository } from "@services/mongoose/accounts"
import { UsersRepository } from "@services/mongoose/users"
import {
PushNotificationsService,
SendFilteredPushNotificationStatus,
} from "@services/notifications/push-notifications"

export const sendUserNotification = async ({
accountId,
title,
body,
sentBy,
}: {
accountId: AccountUuid
title: string
body: string
// The admin/support user who triggered this send. Required — see the audit
// log below for why it cannot be optional.
sentBy: UserId
}): Promise<true | ApplicationError> => {
const account = await AccountsRepository().findByUuid(accountId)
if (account instanceof Error) return account

const user = await UsersRepository().findById(account.kratosUserId)
if (user instanceof Error) return user

// Audit trail. `sentBy` is the only operator attribution this send gets — the
// admin server builds no gqlContext for PinoHttp to log. `body` is on the line
// because it is what lands on the user's lock screen, and it is
// operator-authored text, not user PII. Emitted before the send so the record
// survives a failure mid-delivery.
baseLogger.info(
{ accountId, sentBy, title, body, deviceTokenCount: user.deviceTokens.length },
"admin user notification requested",
)

// The "requested" line counts attempts; every terminal path below logs an
// outcome, so a grep cannot read a refused or failed send as a delivery.
const logOutcome = (outcome: string, extra?: Record<string, unknown>) =>
baseLogger.info(
{ accountId, sentBy, outcome, ...extra },
"admin user notification result",
)

// Filtered, not raw: every other user-facing push in the codebase honors the
// recipient's notification settings, and the domain already models this
// category. A user who turned push off must not receive one anyway.
const result = await PushNotificationsService().sendFilteredNotification({
deviceTokens: user.deviceTokens,
title,
body,
notificationCategory: FlashNotificationCategories.AdminPushNotification,
notificationSettings: account.notificationSettings,
})

// Firebase reports stale (uninstalled/reinstalled) tokens as an error even
// when delivery to the user's live tokens succeeded. Prune them like every
// other push caller does, and only report success when Firebase says at
// least one device actually accepted the push.
if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) {
const pruned = await removeDeviceTokens({
userId: account.kratosUserId,
deviceTokens: result.tokens,
})
if (pruned instanceof Error) {
baseLogger.warn({ accountId, err: pruned }, "failed to prune stale device tokens")
}

if (result.successCount > 0) {
logOutcome("delivered-with-stale-tokens-pruned")
return true
}

// "All stale" has to actually mean all stale: if only some tokens were
// stale, the rest failed for another reason, and "ask them to reopen the
// app" sends the operator to the user for a problem the user cannot fix.
if (result.tokens.length === user.deviceTokens.length) {
logOutcome("all-device-tokens-stale")
return new AllDeviceTokensStaleNotificationsServiceError()
}

logOutcome("no-device-accepted", { failureCodes: result.failureCodes })
return new NoDeviceAcceptedPushNotificationsServiceError(
"no device accepted the push",
result.failureCodes,
)
}

// Every token failed for a non-stale reason. Logged as its own outcome rather
// than folded into "send-failed" so the escalation carries the FCM codes.
if (result instanceof NoDeviceAcceptedPushNotificationsServiceError) {
logOutcome("no-device-accepted", { failureCodes: result.failureCodes })
return result
}

// The user has no registered device tokens at all — nothing was handed to
// Firebase. This is the most common terminal state on a support-initiated
// send and it is routine, not a fault. Folding it into "send-failed" would
// put it under the same label as "Firebase module not loaded" and a Google
// 5xx, so an alert on the one outcome that reads as "something broke" would
// fire on every attempt at a user who never enabled push, get muted, and
// then hide a real outage. The API already reports these distinctly
// (PUSH_NO_DEVICE_TOKENS vs UNEXPECTED_CLIENT_ERROR); the log must too.
if (result instanceof InvalidDeviceNotificationsServiceError) {
logOutcome("no-device-tokens")
return result
}

if (result instanceof Error) {
logOutcome("send-failed")
return result
}

if (result.status === SendFilteredPushNotificationStatus.Filtered) {
logOutcome("filtered-recipient-opted-out")
return new RecipientDisabledNotificationsServiceError()
}

logOutcome("delivered")
return true
}
46 changes: 45 additions & 1 deletion src/domain/notifications/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,55 @@ export class NotificationsServiceError extends NotificationsError {}
export class InvalidDeviceNotificationsServiceError extends NotificationsServiceError {}
export class DeviceTokensNotRegisteredNotificationsServiceError extends NotificationsServiceError {
tokens: DeviceToken[]
constructor(tokens: DeviceToken[], message?: string | unknown | Error) {
// How many devices actually accepted the push. Callers must use this rather
// than inferring delivery from `tokens.length` — a token can fail for reasons
// other than being unregistered, so "not stale" does not mean "delivered".
successCount: number
// Distinct FCM error codes across every token that failed in this batch, so a
// caller that ends up reporting a delivery failure can say why.
failureCodes: string[]
constructor(
tokens: DeviceToken[],
// Defaults to 0 — "nothing is known to have been delivered". The push
// service always passes the real count; the default only applies to
// callers that construct this directly, where claiming a delivery we
// cannot evidence is the dangerous direction to be wrong in.
successCount = 0,
failureCodes: string[] = [],
message?: string | unknown | Error,
) {
super(message)
this.tokens = tokens
this.successCount = successCount
this.failureCodes = failureCodes
}
}
// Every registered device token was stale and has just been pruned. Distinct
// from InvalidDeviceNotificationsServiceError, which means the user never had
// a device token to begin with.
export class AllDeviceTokensStaleNotificationsServiceError extends NotificationsServiceError {}
// Nothing was delivered, but at least one of the user's tokens was *not* stale.
// The undelivered tokens failed for some other reason (expired APNs auth key,
// sender-id mismatch, quota), so this is a push-infrastructure problem — the
// user cannot fix it by reopening the app, and telling them to is a wasted
// support cycle. Distinct from AllDeviceTokensStaleNotificationsServiceError,
// whose name asserts something stronger than "nothing got through".
export class NoDeviceAcceptedPushNotificationsServiceError extends NotificationsServiceError {
// The distinct FCM codes Firebase refused with — "messaging/third-party-auth-error"
// (expired APNs auth key), "messaging/mismatched-credential" (sender-id
// mismatch), "messaging/quota-exceeded", and so on. This error routes the
// operator to engineering, so carry the diagnosis with it rather than making
// eng correlate the per-token warn lines in push-notifications by timestamp.
failureCodes: string[]
constructor(message?: string | unknown | Error, failureCodes: string[] = []) {
super(message)
this.failureCodes = failureCodes
}
}
// The recipient turned push off (or disabled this notification category) in the
// app, so the notification was filtered out and never handed to Firebase.
// Nothing was sent — callers must not report this as a successful send.
export class RecipientDisabledNotificationsServiceError extends NotificationsServiceError {}
export class NotificationsServiceUnreachableServerError extends NotificationsServiceError {
level = ErrorLevel.Critical
}
Expand Down
2 changes: 2 additions & 0 deletions src/graphql/admin/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UserUpdatePhoneMutation from "./root/mutation/user-update-phone"
import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map"
import SendNotificationMutation from "./root/mutation/send-notification"
import sendCashoutSettledNotification from "./root/mutation/cashout-notification-send"
import UserNotificationSendMutation from "./root/mutation/user-notification-send"

import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete"
import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate"
Expand All @@ -26,6 +27,7 @@ export const mutationFields = {
businessDeleteMapInfo: BusinessDeleteMapInfoMutation,
sendNotification: SendNotificationMutation,
cashoutNotificationSend: sendCashoutSettledNotification,
userNotificationSend: UserNotificationSendMutation,
cashWalletCutoverUpdate: CashWalletCutoverUpdateMutation,
cashWalletCutoverRollback: CashWalletCutoverRollbackMutation,
},
Expand Down
184 changes: 184 additions & 0 deletions src/graphql/admin/root/mutation/user-notification-send.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { Admin } from "@app/index"
import { checkedToAccountUuid } from "@domain/accounts"
import {
AllDeviceTokensStaleNotificationsServiceError,
InvalidDeviceNotificationsServiceError,
NoDeviceAcceptedPushNotificationsServiceError,
RecipientDisabledNotificationsServiceError,
} from "@domain/notifications"
import { ValidationError } from "@domain/shared"
import { InputValidationError, PushNotificationError } from "@graphql/error"
import { apolloErrorResponse, mapAndParseErrorForGqlResponse } from "@graphql/error-map"
import { GT } from "@graphql/index"
import SuccessPayload, {
SUCCESS_RESPONSE,
} from "@graphql/shared/types/payload/success-payload"

const MAX_TITLE_LENGTH = 256
const MAX_BODY_LENGTH = 1024

// PushNotificationError defaults to code "FIREBASE_ERROR", which would collapse
// four distinct terminal states into one. Callers (the ERPNext support panel,
// alerting) must be able to tell "the user opted out" — normal, ignore — from
// "push infrastructure is down" — page engineering — without regexing English
// prose that a copy edit silently breaks.
const PushErrorCode = {
AllTokensStale: "PUSH_ALL_TOKENS_STALE",
NoDeviceAccepted: "PUSH_NO_DEVICE_ACCEPTED",
RecipientDisabled: "PUSH_RECIPIENT_DISABLED",
NoDeviceTokens: "PUSH_NO_DEVICE_TOKENS",
} as const

// apolloErrorResponse omits the `success` key (it would read as null);
// make every error branch of this mutation report an explicit success: false
const failureResponse = (e: Parameters<typeof apolloErrorResponse>[0]) => ({
...apolloErrorResponse(e),
success: false,
})

const UserNotificationSendInput = GT.Input({
name: "UserNotificationSendInput",
description:
"Sends a push notification to one user's registered devices. " +
"Honors the recipient's notification settings: a user who has disabled " +
"push, or the AdminPushNotification category, is not sent to and the " +
"mutation returns success: false with 'User has disabled admin " +
"notifications' — a normal terminal state, not a system failure. " +
"Other reachable terminal states: the user has no registered devices, " +
"every registered device token is stale (app uninstalled/reinstalled), " +
"and no device accepted the push (push infrastructure problem — escalate " +
"to engineering rather than to the user).",
fields: () => ({
accountId: {
type: GT.String,
description: "Account uuid of the recipient. Provide this or username.",
},
username: {
type: GT.String,
description: "Username of the recipient. Provide this or accountId.",
},
title: {
type: GT.NonNull(GT.String),
description: `Notification title. Trimmed; 1 to ${MAX_TITLE_LENGTH} characters.`,
},
body: {
type: GT.NonNull(GT.String),
description: `Notification body. Trimmed; 1 to ${MAX_BODY_LENGTH} characters.`,
},
}),
})

const UserNotificationSendMutation = GT.Field<
null,
GraphQLAdminContext,
{
input: {
accountId?: string
username?: string
title: string
body: string
}
}
>({
extensions: {
complexity: 120,
},
type: GT.NonNull(SuccessPayload),
args: {
input: { type: GT.NonNull(UserNotificationSendInput) },
},
resolve: async (_, args, ctx) => {
const { accountId, username, title, body } = args.input
// Threaded into the app layer, which logs it: this is the only record of
// the operator behind an arbitrary push to a named user.
const sentBy = ctx.user.id

if (!accountId === !username)
return failureResponse(
new InputValidationError({
message: "Exactly one of accountId or username is required",
}),
)

const trimmedTitle = title.trim()
if (trimmedTitle.length === 0 || trimmedTitle.length > MAX_TITLE_LENGTH)
return failureResponse(
new InputValidationError({
message: `Title must be between 1 and ${MAX_TITLE_LENGTH} characters`,
}),
)

const trimmedBody = body.trim()
if (trimmedBody.length === 0 || trimmedBody.length > MAX_BODY_LENGTH)
return failureResponse(
new InputValidationError({
message: `Body must be between 1 and ${MAX_BODY_LENGTH} characters`,
}),
)

let checkedAccountId: AccountUuid
if (accountId) {
const checked = checkedToAccountUuid(accountId)
if (checked instanceof Error)
return failureResponse(new InputValidationError({ message: "Invalid accountId" }))
checkedAccountId = checked
} else {
// getAccountByUsername validates the username format itself, but
// UsernameRegex rejects surrounding whitespace — trim so a username
// pasted out of a support ticket resolves instead of reading as invalid.
const account = await Admin.getAccountByUsername((username as string).trim())
if (account instanceof ValidationError)
return failureResponse(new InputValidationError({ message: "Invalid username" }))
if (account instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(account)], success: false }
}
checkedAccountId = account.uuid
}

const result = await Admin.sendUserNotification({
accountId: checkedAccountId,
title: trimmedTitle,
body: trimmedBody,
sentBy,
})
if (result instanceof AllDeviceTokensStaleNotificationsServiceError)
return failureResponse(
new PushNotificationError({
code: PushErrorCode.AllTokensStale,
message:
"All of the user's registered devices are stale (app uninstalled or reinstalled). Tokens were cleared; ask them to reopen the app.",
}),
)
if (result instanceof NoDeviceAcceptedPushNotificationsServiceError)
return failureResponse(
new PushNotificationError({
code: PushErrorCode.NoDeviceAccepted,
message:
"Delivery failed for reasons other than stale tokens — check push infrastructure, not the user. Escalate to engineering rather than retrying.",
}),
)
if (result instanceof RecipientDisabledNotificationsServiceError)
return failureResponse(
new PushNotificationError({
code: PushErrorCode.RecipientDisabled,
message:
"User has disabled admin notifications. Nothing was sent — they must re-enable notifications in the app before this will reach them.",
}),
)
if (result instanceof InvalidDeviceNotificationsServiceError)
return failureResponse(
new PushNotificationError({
code: PushErrorCode.NoDeviceTokens,
message:
"User has no registered device tokens. They may not have logged in on a device with notifications enabled.",
}),
)
if (result instanceof Error) {
return { errors: [mapAndParseErrorForGqlResponse(result)], success: false }
}

return SUCCESS_RESPONSE
},
})

export default UserNotificationSendMutation
Loading
Loading