feat(admin): userNotificationSend — push notification to a single user - #489
Merged
Conversation
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
force-pushed
the
feat/user-notification-send
branch
from
August 19, 2026 19:48
3863a1f to
b8ddbb1
Compare
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
approved these changes
Aug 19, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
sendNotificationmutation is FCM topic broadcast only; the per-user plumbing (PushNotificationsService().sendNotification({ deviceTokens, ... })) existed but was only wired for cashout notifications.What
New admin mutation:
src/app/admin/send-user-notification.ts— account → kratos user → device tokens → push (mirrorssendCashoutNotification, 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 tokensuserNotificationSendin the admin schema; SDL regenerated viayarn write-sdlTests
test/flash/unit/app/admin/send-user-notification.spec.ts— happy path, kratos lookup, account/user/push-service error propagationtest/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 mappingyarn test:unit(196 suites, 1943 tests) green;yarn tsc-checkand 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
successfromresult.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 getsmessaging/third-party-auth-error,invalidTokensstays empty, the service returned baretrue, and the operator sawsuccess: truewith nothing delivered. Same fail-open shape as the phantom "conversion successful" in #483.push-notifications.tsnow returns aNotificationsServiceErrorwhen Firebase is not initialised (was a FIXME'dreturn true) and when the batch came back withsuccessCount === 0.DeviceTokensNotRegisteredNotificationsServiceErrorcarries the realsuccessCount;sendUserNotificationkeys success off it.AllDeviceTokensStaleNotificationsServiceErrorseparates "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".usernameis trimmed before lookup (UsernameRegexrejects 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
.tokensoff the stale-token error or logs-and-continues on anyNotificationsServiceError—send-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 (allPromise<void>). No payment or deposit path aborts because a push failed. The siblingcashoutNotificationSendmutation 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 +successCountplumbing); app spec coverssuccessCount0 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.