Skip to content

feat: Discord ops activity feed for key user flows#458

Merged
islandbitcoin merged 6 commits into
mainfrom
ops-discord-events
Jul 23, 2026
Merged

feat: Discord ops activity feed for key user flows#458
islandbitcoin merged 6 commits into
mainfrom
ops-discord-events

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a fire-and-forget ops notification feed: the backend posts color-coded Discord embeds to a webhook (OPS_DISCORD_WEBHOOK_URL) as users move through key flows, so ops can watch flows happen live and pinpoint mid-flow breakage. Built as a sibling of the existing alertBridge module in src/services/alerts/ and follows the same never-throw, never-awaited idiom.

Flows covered

  • Verification funnel (trial → verified): OTP verified → promotion (level 0→1), including upgrade-collision events for PhoneAccountAlreadyExistsNeedToSweepFundsError / PhoneAccountAlreadyExistsCannotUpgradeError (with deviceHasBalance flag) and upgrade-failed for bad OTP / onboarding / kratos schema failures. Note: phone otp-sent fires only on the add-phone flow (userPhoneRegistrationInitiate); the upgrade path requests its OTP via the captcha/login mutation, which is deliberately unhooked (login noise), so the upgrade funnel's first visible event is its outcome. Email funnel: sent → verified (Kratos identity only, no level change — by design).
  • Upgrade: business upgrade requested (ERPNext post) and approved (updateAccountLevel, old→new level).
  • Cashout: initiated / succeeded / failed with the failing step (validation / draftCashout / payInvoice / submitCashout). On the submit double-failure (payment sent, ERPNext record failed) exactly one truthful failed event fires — the client-facing return is unchanged, but the feed shows the desync (InitiatedCashout.erpSubmitted).
  • Deposit: Bridge deposit webhook, IBEX lightning/onchain receives.
  • Transfer: intraledger + lightning sends including no-amount invoices (success/pending/failed, with amounts where the call carries them; failures distinguish error-return vs status-failure), Bridge transfer webhook.

Privacy / safety

  • Phone masked to +1876…00, email to j***@gmail.com; all IDs truncated to 8 chars. No names, no full wallet IDs. Amounts are display units (95.40 USD, not cents).
  • Unset OPS_DISCORD_WEBHOOK_URL ⇒ complete no-op. Delivery is sequential with one 429 retry_after retry, 3s timeout, 50-event queue cap with a dropped-events summary embed. Call sites never change control flow.

Config

OPS_DISCORD_WEBHOOK_URL (optional) added to the env schema. Needs wiring into api, IBEX-webhook, and Bridge-webhook deployments (charts follow-up; ALERT_DISCORD_WEBHOOK_URL is also not in templates yet).

Tests

8 spec files, ~80 new unit tests (masking, embed shape, real queue/429/overflow behavior, per-hook flow/phase assertions incl. collision variants, no-succeeded-after-partial-failure, display-unit amounts). Full unit suite: 945 passed, 0 failed; yarn tsc-check and eslint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7

bobodread876 and others added 3 commits July 22, 2026 14:45
Add a fire-and-forget ops event feed that posts color-coded Discord
embeds to OPS_DISCORD_WEBHOOK_URL as users move through key flows, so
the ops channel reads as a funnel (otp-sent -> otp-verified -> promoted
share an account/user id).

- src/services/alerts/ops-events.ts: notifyOpsEvent() mirroring
  alertBridge (returns void, never throws, no-op when env unset, 3s
  timeout). Sequential in-process FIFO queue, honors Discord 429
  retry_after once, drops oldest past 50 queued and emits one
  "N events dropped" summary embed. Identity is always masked
  (phone -> +1876...00, email -> j***@gmail.com) and long ids
  truncated to 8 chars. Env label from NETWORK.
- config: OPS_DISCORD_WEBHOOK_URL optional zod env var + export.
- Hooks (all post-outcome, never alter control flow):
  verification: requestPhoneCodeForAuthedUser, verifyPhone,
  addEmailToIdentity, verifyEmail, upgradeAccountFromDeviceToPhone
  upgrade: createUpgradeRequest, updateAccountLevel
  cashout: CashoutManager.executeCashout (initiated/succeeded),
  ValidOffer.execute (per-step failures: draftCashout / payInvoice /
  submitCashout)
  deposit: bridge depositHandler, IBEX on-receive (ln + onchain)
  transfer: bridge transferHandler, intraledger wallet-id sends,
  payInvoiceByWalletId
- Tests: masking/truncation edge cases, embed shape/colors, no-op
  when unset, 429 retry, queue-overflow drop summary, plus
  success/failure call-site coverage for every hooked app function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
Hook loginDeviceUpgradeWithPhone into the ops event feed: the two
phone-collision returns (device balance must be swept / zero-balance
cannot-upgrade) emit "upgrade-collision" failed events with the error
class name and meta.deviceHasBalance distinguishing the variant, so ops
sees every user silently hitting the collision wall. Happy-path
error returns (invalid OTP, onboarding check, kratos schema upgrade,
account promotion failure) emit "upgrade-failed" via the same small
helper. Rate-limit failures are intentionally not notified (noise).
Control flow is unchanged.

Tests cover both collision variants, upgrade-failed on invalid OTP and
kratos failure, and that the successful path adds no extra events
beyond the promotion emitted by upgradeAccountFromDeviceToPhone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
…amount lightning hooks

- Cashout partial failure no longer reads as success: ValidOffer.execute
  surfaces the ERPNext submit outcome via InitiatedCashout.erpSubmitted
  (default true; GraphQL output unchanged) and CashoutManager gates its
  "succeeded" emit on it — exactly one truthful terminal event per
  cashout (the failed(submitCashout) event comes from execute()).
- Cashout amounts now render in display units via toDisplayAmount()
  ($95.40, not "9540 USD"): USDAmount/JMDAmount.asDollars(),
  USDTAmount.asNumber(2).
- ValidOffer.from() validation failure now emits a terminal
  failed(validation) event so an "initiated" emit can never be left
  permanently amber.
- payNoAmountInvoiceByWalletIdForBtcWallet/ForUsdWallet hooked like
  payInvoiceByWalletId via a shared notifyLightningSendResult helper;
  intraledger notifications moved into the exported wrappers so their
  wallet-currency validation failures are captured too.
- Transfer events carry display amounts where the args have them
  (intraledger + no-amount lightning: cents -> USD dollars, sats for
  BTC); invoice-amount lightning sends have no amount arg and stay
  amountless.
- Failed transfer events distinguish meta.reason "error-return" vs
  "status-failure" (Ibex status 3 / PaymentSendStatus.Failure).

Tests updated/added: erpSubmitted flag on both execute() outcomes, no
"succeeded" after partial failure, from()-validation terminal event,
display-amount assertions across cashout/intraledger/lightning,
no-amount wrapper hooks (USD success + BTC validation failure),
wrapper-validation intraledger failure, reason distinction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
yarn audit now flags a critical DoS advisory in tar (<7.5.19,
npmjs.com/advisories/1123940, pulled in via @apollo/rover >
binary-install), which fails the CI Audit gate for every PR. Bump the
existing **/tar resolution from 7.5.11 to the patched 7.5.19.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
@islandbitcoin
islandbitcoin merged commit a86dc79 into main Jul 23, 2026
15 checks passed
islandbitcoin pushed a commit to lnflash/charts that referenced this pull request Jul 23, 2026
…ents (chart 3.2.42)

Adds OPS_DISCORD_WEBHOOK_URL and ALERT_DISCORD_WEBHOOK_URL to the api,
ibex-webhook, and bridge-webhook deployments via secretKeyRef against a
new 'discord-webhooks' secret (keys: ops-webhook-url, alert-webhook-url),
optional: true so environments without the secret still schedule pods.
Follows the *ExistingSecret convention; galoy-secrets.yaml renders the
secret for secrets.create=true installs, omitting keys whose values are
empty (an empty-string env var would fail the backend's zod .url()
validation and crash pods at boot — caught by the dev smoketest).

Backend consumer: lnflash/flash#458 (ops activity feed + alerts module).
Real deployments provision the secret out-of-band (secrets.create=false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
islandbitcoin added a commit to lnflash/charts that referenced this pull request Jul 23, 2026
…ents (chart 3.2.42) (#240)

Adds OPS_DISCORD_WEBHOOK_URL and ALERT_DISCORD_WEBHOOK_URL to the api,
ibex-webhook, and bridge-webhook deployments via secretKeyRef against a
new 'discord-webhooks' secret (keys: ops-webhook-url, alert-webhook-url),
optional: true so environments without the secret still schedule pods.
Follows the *ExistingSecret convention; galoy-secrets.yaml renders the
secret for secrets.create=true installs, omitting keys whose values are
empty (an empty-string env var would fail the backend's zod .url()
validation and crash pods at boot — caught by the dev smoketest).

Backend consumer: lnflash/flash#458 (ops activity feed + alerts module).
Real deployments provision the secret out-of-band (secrets.create=false).


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

Co-authored-by: Dread <dread@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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