Skip to content

fix(notifications): send organization low-balance alerts to owners and admins - #1863

Open
fergusfinn wants to merge 1 commit into
mainfrom
fix/org-low-balance-recipients
Open

fergusfinn wants to merge 1 commit into
mainfrom
fix/org-low-balance-recipients

Conversation

@fergusfinn

@fergusfinn fergusfinn commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Low-balance notifications for an organization were sent only to the organization's contact email, which is typically the address of whoever created it. Other owners and admins, who can also add credits, never heard about a low balance.

The notification now goes to the organization's contact email plus every active owner and admin. Plain members are not mailed, soft-deleted accounts are skipped (via the existing list_admin_emails query), and each address is mailed once, case-insensitively. Personal accounts have no members, so their behaviour is unchanged.

The notification-sent flag is set once at least one recipient was reached. Leaving it unset on a partial failure would re-mail the recipients who already got it on every tick.

Tests

  • test_low_balance_recipients_lists_each_address_once: de-duplication of the recipient list.
  • test_low_balance_notification_reaches_org_owners_and_admins: builds an org with an owner, an admin and a member, sends through the file email transport, and asserts the contact address, owner and admin each get exactly one email, the member gets none, and the org is marked as notified.

cargo test --lib notifications:: passes (20 tests).

Review in cubic

Copilot AI lite review requested due to automatic review settings September 28, 2026 15:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T15:18:21.658254Z 93497ac PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: 93497ac
Status: ✅  Deploy successful!
Preview URL: https://6e34035a.control-layer.pages.dev
Branch Preview URL: https://fix-org-low-balance-recipien.control-layer.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93497ac743

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1211 to +1218
let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {
crate::background_error!(
NOTIFICATIONS, "email_send", Warning,
NOTIFICATIONS, "low_balance_recipients", Warning,
user_id = %user.id,
email = %user.email,
error = %e,
"Failed to send low-balance notification email"
"Failed to list organization admins for a low-balance notification"
);
continue;
Vec::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry when the administrator lookup fails

When list_admin_emails fails transiently, converting the error to an empty recipient list still sends to the contact address; that successful delivery then causes low_balance_notification_sent to be set below. Since subsequent ticks exclude accounts with that flag, none of the owners or administrators are retried until the balance first recovers above the threshold, defeating the new notification behavior. Skip this account on lookup failure so the complete recipient lookup can be retried.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Recipient lookup failures can suppress future notifications, and membership lookups add avoidable per-candidate database work.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Expands organization low-balance alerts to reach active owners and admins while avoiding duplicate emails.

Changes:

  • Added owner/admin recipient lookup and case-insensitive deduplication.
  • Added delivery tracking and notification tests.
  • Preserved existing personal-account behavior.
File Summary
dwctl/​src/​notifications.rs Implements recipient fan-out and tests; two moderate issues remain around lookup failure handling and per-candidate database queries.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

"Failed to list organization admins for a low-balance notification"
);
continue;
Vec::new()

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="dwctl/src/notifications.rs">

<violation number="1" location="dwctl/src/notifications.rs:1211">
P2: This performs one database round trip per low-balance account, including personal accounts, so a large alert batch adds serial N+1 queries before mailing. Batch the lookup for all due organization IDs.</violation>

<violation number="2" location="dwctl/src/notifications.rs:1211">
P2: When `list_admin_emails` fails, the fallback to an empty recipient list silently removes all owners and admins from the alert, and if the contact email is then delivered the org is permanently marked as notified — the admins never got the email and never get retried. This differs from the intentional partial email-send trade-off documented above: on a listing failure nothing has been sent to the admins yet, so skipping this account (`continue`) and retrying next tick would re-mail nobody. Return the error out of the listing instead of degrading to contact-only, or the low-balance alert can miss every credit-adding member for good after a single transient query failure.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

let name = user.display_name.as_deref().unwrap_or(&user.username);

if let Err(e) = email_service.send_low_balance_email(&user.email, Some(name), &balance).await {
let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This performs one database round trip per low-balance account, including personal accounts, so a large alert batch adds serial N+1 queries before mailing. Batch the lookup for all due organization IDs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dwctl/src/notifications.rs, line 1211:

<comment>This performs one database round trip per low-balance account, including personal accounts, so a large alert batch adds serial N+1 queries before mailing. Batch the lookup for all due organization IDs.</comment>

<file context>
@@ -1202,19 +1208,35 @@ async fn send_low_balance_notifications(
         let name = user.display_name.as_deref().unwrap_or(&user.username);
 
-        if let Err(e) = email_service.send_low_balance_email(&user.email, Some(name), &balance).await {
+        let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {
             crate::background_error!(
-                NOTIFICATIONS, "email_send", Warning,
</file context>

Comment on lines +1211 to +1219
let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {
crate::background_error!(
NOTIFICATIONS, "email_send", Warning,
NOTIFICATIONS, "low_balance_recipients", Warning,
user_id = %user.id,
email = %user.email,
error = %e,
"Failed to send low-balance notification email"
"Failed to list organization admins for a low-balance notification"
);
continue;
Vec::new()
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When list_admin_emails fails, the fallback to an empty recipient list silently removes all owners and admins from the alert, and if the contact email is then delivered the org is permanently marked as notified — the admins never got the email and never get retried. This differs from the intentional partial email-send trade-off documented above: on a listing failure nothing has been sent to the admins yet, so skipping this account (continue) and retrying next tick would re-mail nobody. Return the error out of the listing instead of degrading to contact-only, or the low-balance alert can miss every credit-adding member for good after a single transient query failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dwctl/src/notifications.rs, line 1211:

<comment>When `list_admin_emails` fails, the fallback to an empty recipient list silently removes all owners and admins from the alert, and if the contact email is then delivered the org is permanently marked as notified — the admins never got the email and never get retried. This differs from the intentional partial email-send trade-off documented above: on a listing failure nothing has been sent to the admins yet, so skipping this account (`continue`) and retrying next tick would re-mail nobody. Return the error out of the listing instead of degrading to contact-only, or the low-balance alert can miss every credit-adding member for good after a single transient query failure.</comment>

<file context>
@@ -1202,19 +1208,35 @@ async fn send_low_balance_notifications(
         let name = user.display_name.as_deref().unwrap_or(&user.username);
 
-        if let Err(e) = email_service.send_low_balance_email(&user.email, Some(name), &balance).await {
+        let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {
             crate::background_error!(
-                NOTIFICATIONS, "email_send", Warning,
</file context>
Suggested change
let admin_emails = Organizations::new(&mut *conn).list_admin_emails(user.id).await.unwrap_or_else(|e| {
crate::background_error!(
NOTIFICATIONS, "email_send", Warning,
NOTIFICATIONS, "low_balance_recipients", Warning,
user_id = %user.id,
email = %user.email,
error = %e,
"Failed to send low-balance notification email"
"Failed to list organization admins for a low-balance notification"
);
continue;
Vec::new()
});
let admin_emails = match Organizations::new(&mut *conn).list_admin_emails(user.id).await {
Ok(emails) => emails,
Err(e) => {
crate::background_error!(
NOTIFICATIONS, "low_balance_recipients", Warning,
user_id = %user.id,
error = %e,
"Failed to list organization admins for a low-balance notification"
);
continue;
}
};

This branch has not been deployed

No deployments
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