Skip to content

feat(admin): userNotificationSend — push notification to a single user - #489

Merged
islandbitcoin merged 1 commit into
mainfrom
feat/user-notification-send
Aug 20, 2026
Merged

feat(admin): userNotificationSend — push notification to a single user#489
islandbitcoin merged 1 commit into
mainfrom
feat/user-notification-send

Conversation

@bobodread876

@bobodread876 bobodread876 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Why

Support needs to reach a specific user in the app — e.g. ask a user on an old build to update, or tell them their top-up was resolved. The existing admin sendNotification mutation is FCM topic broadcast only; the per-user plumbing (PushNotificationsService().sendNotification({ deviceTokens, ... })) existed but was only wired for cashout notifications.

What

New admin mutation:

mutation {
  userNotificationSend(input: {
    username: "jaceth2009"        # or accountId: "<uuid>" — exactly one required
    title: "Please update Flash"
    body: "A new version fixes the issue you reported."
  }) {
    errors { message code }
    success
  }
}
  • src/app/admin/send-user-notification.ts — account → kratos user → device tokens → push (mirrors sendCashoutNotification, but awaits the service result properly)
  • src/graphql/admin/root/mutation/user-notification-send.ts — exactly-one-of accountId/username validation, trim + length caps (title 256, body 1024), and a clear operator-facing error when the user has no registered device tokens
  • Registered as userNotificationSend in the admin schema; SDL regenerated via yarn write-sdl

Tests

  • test/flash/unit/app/admin/send-user-notification.spec.ts — happy path, kratos lookup, account/user/push-service error propagation
  • test/flash/unit/graphql/admin/user-notification-send.spec.ts — resolver-level: input validation (both/neither identifier, empty/oversize title+body, malformed accountId/username), by-accountId and by-username sends, trim behavior, username-not-found, no-device-tokens, push-failure mapping

yarn test:unit (196 suites, 1943 tests) green; yarn tsc-check and eslint clean.

Follow-up

ERPNext admin panel (frappe-flash-admin) gets a "specific user" target on the alert-users page that calls this mutation — separate PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR

Review follow-up — observed delivery, not inferred (72ef392)

The first cut inferred success from result.tokens.length < user.deviceTokens.length — "was at least one token NOT reported stale?", which is not "did any device receive it?". A user whose only iOS device has an expired APNs auth key gets messaging/third-party-auth-error, invalidTokens stays empty, the service returned bare true, and the operator saw success: true with nothing delivered. Same fail-open shape as the phantom "conversion successful" in #483.

  • push-notifications.ts now returns a NotificationsServiceError when Firebase is not initialised (was a FIXME'd return true) and when the batch came back with successCount === 0.
  • DeviceTokensNotRegisteredNotificationsServiceError carries the real successCount; sendUserNotification keys success off it.
  • New AllDeviceTokensStaleNotificationsServiceError separates "every token was stale and has just been pruned" from "user never had a device token" — the operator message for the first case now says the tokens were cleared and to ask the user to reopen the app, rather than sending them chasing "they never logged in".
  • The stale-token prune result is no longer discarded — a failed mongo write logs a warning instead of silently leaving dead tokens for every retry to re-send to.
  • username is trimmed before lookup (UsernameRegex rejects surrounding whitespace, so a value pasted out of a support ticket read as "Invalid username").

Caller safety for the new error branches: every existing push caller either reads .tokens off the stale-token error or logs-and-continues on any NotificationsServiceErrorsend-lightning, on-receive, update-pending-invoices, settle-payout-txn, add-settled-on-chain-transaction, add-pending-on-chain-transaction, send-default-wallet-balance-to-users, and the bridge/referral best-effort wrappers (all Promise<void>). No payment or deposit path aborts because a push failed. The sibling cashoutNotificationSend mutation now correctly reports failure where it previously reported a phantom success.

New/extended tests: test/flash/unit/services/notifications/push-notifications.spec.ts (both fail-open cases + successCount plumbing); app spec covers successCount 0 vs >0 and the prune-failure warning; the resolver spec now asserts which error message comes back (username-not-found vs invalid, firebase-down, all-stale vs no-tokens) instead of only counting errors, plus username trimming. Full unit suite: 197 suites / 1955 tests green.

Support needs to reach one specific user in the app — e.g. ask a user
on an old build to update. The existing admin sendNotification mutation
is FCM topic broadcast only; the per-user plumbing existed but was
wired for cashout notifications alone.

  userNotificationSend(input: {
    username: "jaceth2009"   # or accountId — exactly one required
    title: "...", body: "..."
  }) { errors { message code } success }

- app: account -> kratos user -> device tokens -> push, honoring the
  recipient's notification settings like every other user-facing push
- resolver: exactly-one-of validation, trim + length caps, an explicit
  success:false on every error branch, and distinct error codes so the
  caller can tell 'user opted out' (normal) from 'push infrastructure
  is down' (escalate) without matching on English prose
- the send reports observed delivery instead of inferring it: Firebase
  reports stale tokens as an error even when live devices got the push,
  and a batch where every token failed for a non-stale reason (expired
  APNs key) previously returned success. Stale tokens are pruned like
  every other caller does, and success now means Firebase said at least
  one device accepted it
- operator attribution and per-outcome logging: the admin server never
  populates gqlContext, so nothing else records who sent what

Tests: 2191 pass (3 new suites covering the app layer, the resolver and
the push service).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
@bobodread876
bobodread876 force-pushed the feat/user-notification-send branch from 3863a1f to b8ddbb1 Compare August 19, 2026 19:48
bobodread876 pushed a commit to lnflash/frappe-flash-admin that referenced this pull request Aug 19, 2026
… page

/app/alert-users could only broadcast to an FCM topic. This adds a
'Specific user' audience that calls the new userNotificationSend Flash
admin mutation (lnflash/flash#489), so support can reach one user
directly — e.g. ask them to update their app.

- graphql_client: USER_NOTIFICATION_SEND_MUTATION + send_user_alert()
- admin_api: send_user_alert(username, title, message) — admin-gated,
  bounded inputs, logs a DIRECT User Alerts row with target_username
- User Alerts doctype: optional target_username audit field
- page: audience selector, username input, per-audience confirm /
  preview / history, with operator input and topic names escaped

Guards worth calling out, both for hazards the audit trail creates:
- the push cannot be recalled once sent, so a failed audit-row write is
  never reported as a failed send — otherwise the operator resends and
  the customer gets the same personal message twice
- target_username only exists after bench migrate, and the erpnext
  chart's migrate Job has no helm hook, so a fresh pod can serve this
  against a table without the column. Writes refuse (503) rather than
  persisting a DIRECT row that lost its target permanently; reads report
  the column's absence so the page cannot relabel a private message as
  a broadcast.
- get_user_alerts / get_alert_types are admin-gated: the Page doc
  carries roles: [], so the page opens for any logged-in user, and the
  history quotes what support said to a named customer.

Tests: 407 pass. pre-commit clean.

Requires the Flash API deployed with lnflash/flash#489.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
islandbitcoin pushed a commit to lnflash/frappe-flash-admin that referenced this pull request Aug 20, 2026
… page (#68)

/app/alert-users could only broadcast to an FCM topic. This adds a
'Specific user' audience that calls the new userNotificationSend Flash
admin mutation (lnflash/flash#489), so support can reach one user
directly — e.g. ask them to update their app.

- graphql_client: USER_NOTIFICATION_SEND_MUTATION + send_user_alert()
- admin_api: send_user_alert(username, title, message) — admin-gated,
  bounded inputs, logs a DIRECT User Alerts row with target_username
- User Alerts doctype: optional target_username audit field
- page: audience selector, username input, per-audience confirm /
  preview / history, with operator input and topic names escaped

Guards worth calling out, both for hazards the audit trail creates:
- the push cannot be recalled once sent, so a failed audit-row write is
  never reported as a failed send — otherwise the operator resends and
  the customer gets the same personal message twice
- target_username only exists after bench migrate, and the erpnext
  chart's migrate Job has no helm hook, so a fresh pod can serve this
  against a table without the column. Writes refuse (503) rather than
  persisting a DIRECT row that lost its target permanently; reads report
  the column's absence so the page cannot relabel a private message as
  a broadcast.
- get_user_alerts / get_alert_types are admin-gated: the Page doc
  carries roles: [], so the page opens for any logged-in user, and the
  history quotes what support said to a named customer.

Tests: 407 pass. pre-commit clean.

Requires the Flash API deployed with lnflash/flash#489.


Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR

Co-authored-by: Dread <dread@example.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@islandbitcoin
islandbitcoin merged commit 5ac44d2 into main Aug 20, 2026
15 checks passed
@linear

linear Bot commented Aug 20, 2026

Copy link
Copy Markdown

ENG-551

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants