Skip to content

Notifications (in-app + email digest)#94

Open
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-157-notifications-in-app-email-digest
Open

Notifications (in-app + email digest)#94
aloewright wants to merge 1 commit into
mainfrom
conductor/alo-157-notifications-in-app-email-digest

Conversation

@aloewright
Copy link
Copy Markdown
Owner

@aloewright aloewright commented May 8, 2026

Closes ALO-157.

Summary

  • Migration 0019 adds email_digest_frequency (off/daily/weekly, default weekly) and email_digest_last_sent_at columns directly on the user table.
  • /api/users/me/notifications/unread-count powers the header bell badge — counts subscription_inbox rows with seen_at IS NULL (capped at 99+).
  • /api/users/me/notifications/preferences (GET/PUT) lets users opt out or change cadence.
  • runDigestSweep lives in src/workers/digest.ts and is invoked from the existing scheduled handler (daily 02:00 UTC cron). It selects users whose digest window has elapsed, aggregates new uploads from subscription_inbox since their last digest, and sends a single Resend HTML email. Best-effort: last_sent_at only advances on Resend success.
  • Frontend bell (NotificationBell) polls every 60s, opens a popover with recent inbox items, marks them seen on open.
  • Account settings gains a Weekly / Daily / Off radio group under a #notifications anchor that the bell links to.

Test plan

  • npm test — all 511 tests pass (includes new notifications.test.ts, digest.test.ts, and the migration assertion)
  • npm run lint — 0 findings, AI Gateway guard clean
  • npm run type-check
  • npm run build — frontend bundles cleanly
  • Manual: bell badge increments after a subscribed creator uploads; setting digest to off suppresses the email and clears last_sent_at from the prefs UI hint.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 8, 2026 14:36
@aloewright aloewright added the conductor Conductor-managed PR label May 8, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 8, 2026

Warning

Rate limit exceeded

@aloewright has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 53 minutes and 17 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dd4809c5-e74e-4b85-8019-666ae67c0e58

📥 Commits

Reviewing files that changed from the base of the PR and between 4d3c13f and d03b059.

📒 Files selected for processing (11)
  • src/db/migrations.test.ts
  • src/db/migrations/0019_notifications.sql
  • src/frontend/App.tsx
  • src/frontend/components/NotificationBell.tsx
  • src/frontend/components/NotificationPreferences.tsx
  • src/frontend/pages/AccountSettings.tsx
  • src/workers/digest.test.ts
  • src/workers/digest.ts
  • src/workers/index.ts
  • src/workers/notifications.test.ts
  • src/workers/notifications.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch conductor/alo-157-notifications-in-app-email-digest

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 8, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

Copy link
Copy Markdown

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

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: 886c4452c6

ℹ️ 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 thread src/workers/notifications.ts Outdated
Comment on lines +241 to +245
await sendEmail(env, { to: row.email, subject, html });
stats.digestsSent++;
stats.itemsSent += items.results.length;

await env.DB.prepare(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retry failed digest sends before advancing cursor

sendEmail can return a non-OK result on transient API/network failures, but this code ignores the result, increments sent counters, and then advances last_digest_at anyway. When that happens, no digest is delivered yet future sweeps filter on newer added_at values, so the missed inbox items are skipped permanently instead of being retried.

Useful? React with 👍 / 👎.

Comment thread src/workers/notifications.ts Outdated
JOIN videos v ON v.id = i.video_id AND v.deleted_at IS NULL
LEFT JOIN user u ON u.id = i.channel_user_id
WHERE i.subscriber_user_id = ?
AND (? IS NULL OR i.added_at > ?)
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 Include timestamp ties when filtering digest items

Using i.added_at > ? with a cursor that is written via CURRENT_TIMESTAMP can lose notifications at second boundaries. If an inbox row is inserted in the same second that last_digest_at is updated (for example between the item query and cursor update), it gets added_at == last_digest_at and will never match subsequent sweeps, causing silent drops.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a notification system featuring an in-app notification bell and a scheduled email digest for new subscription uploads. Key additions include a notification_prefs database table, frontend components for managing settings and displaying notifications, and backend logic for the digest sweep. Review feedback identifies critical issues such as missing API endpoints for the inbox and seen status, scalability concerns regarding sequential processing in the worker, and logic errors in the digest cursor update that could result in missed notifications. Additionally, improvements for accessibility and error handling during email delivery were recommended.

Comment on lines +64 to +71
const r = await fetch('/api/users/me/inbox?limit=10', { credentials: 'include' });
if (r.ok) {
const data = (await r.json()) as { items: InboxItem[] };
setItems(data.items);
}
// Mark unseen as seen — fire-and-forget.
if (unread > 0) {
await fetch('/api/users/me/inbox/seen', { method: 'POST', credentials: 'include' });
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.

critical

The frontend component attempts to fetch from /api/users/me/inbox and post to /api/users/me/inbox/seen, but these endpoints are not implemented in the notificationRoutes within src/workers/notifications.ts or anywhere else in the provided diff. This will cause the notification bell to fail when opened.

Comment thread src/workers/notifications.ts Outdated
Comment on lines +203 to +254
for (const row of results) {
stats.consideredUsers++;
if (!digestDue({ digest_frequency: row.digest_frequency, last_digest_at: row.last_digest_at }, nowMs)) {
continue;
}
// Pull items added since last digest (or all unseen if first run).
const cutoff = row.last_digest_at;
const items = await env.DB.prepare(
`SELECT i.video_id, i.added_at,
v.title, v.thumbnail_url,
u.name AS channel_name, u.username AS channel_username
FROM subscription_inbox i
JOIN videos v ON v.id = i.video_id AND v.deleted_at IS NULL
LEFT JOIN user u ON u.id = i.channel_user_id
WHERE i.subscriber_user_id = ?
AND (? IS NULL OR i.added_at > ?)
ORDER BY i.added_at DESC
LIMIT 25`,
)
.bind(row.user_id, cutoff, cutoff)
.all<DigestItem>();

if (items.results.length === 0) {
// Nothing new — bump the cursor so we don't re-evaluate every cron.
await env.DB.prepare(
`INSERT INTO notification_prefs (user_id, digest_frequency, last_digest_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
last_digest_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
)
.bind(row.user_id, row.digest_frequency)
.run();
continue;
}

const { subject, html } = renderDigestEmail(items.results, origin);
// sendEmail is fail-open when RESEND_API_KEY is unset.
await sendEmail(env, { to: row.email, subject, html });
stats.digestsSent++;
stats.itemsSent += items.results.length;

await env.DB.prepare(
`INSERT INTO notification_prefs (user_id, digest_frequency, last_digest_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
last_digest_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
)
.bind(row.user_id, row.digest_frequency)
.run();
}
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.

high

The runDigestSweep function iterates over all users sequentially, performing multiple database queries and a network request (email delivery) for each. This approach is not scalable and will likely exceed the Cloudflare Worker execution time limit and subrequest limit (50 on free, 1000 on paid) as the user base grows. Consider processing users in batches or using a more efficient query to identify only those users who actually have new items since their last_digest_at before entering the loop.

Comment thread src/workers/notifications.ts Outdated
Comment on lines +220 to +235
LIMIT 25`,
)
.bind(row.user_id, cutoff, cutoff)
.all<DigestItem>();

if (items.results.length === 0) {
// Nothing new — bump the cursor so we don't re-evaluate every cron.
await env.DB.prepare(
`INSERT INTO notification_prefs (user_id, digest_frequency, last_digest_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
last_digest_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
)
.bind(row.user_id, row.digest_frequency)
.run();
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.

high

Updating last_digest_at to CURRENT_TIMESTAMP while using a LIMIT 25 in the fetch query can lead to missed notifications. If a user has more than 25 new items, the older items (beyond the first 25) will be skipped in the current digest and will never be included in future digests because the cursor moves past them. It is safer to update the cursor to the added_at timestamp of the most recent item actually included in the digest.

Comment thread src/workers/notifications.ts Outdated
Comment on lines +241 to +253
await sendEmail(env, { to: row.email, subject, html });
stats.digestsSent++;
stats.itemsSent += items.results.length;

await env.DB.prepare(
`INSERT INTO notification_prefs (user_id, digest_frequency, last_digest_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
last_digest_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`,
)
.bind(row.user_id, row.digest_frequency)
.run();
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.

medium

The last_digest_at cursor is updated regardless of whether the email delivery via sendEmail was successful. If the email fails to send, the user will miss those notifications entirely because the cursor has already moved forward. You should check the result of sendEmail and only update the database if it succeeds.

    const emailResult = await sendEmail(env, { to: row.email, subject, html });
    if (emailResult.ok) {
      stats.digestsSent++;
      stats.itemsSent += items.results.length;

      await env.DB.prepare(
        `INSERT INTO notification_prefs (user_id, digest_frequency, last_digest_at, updated_at)
         VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
         ON CONFLICT(user_id) DO UPDATE SET
           last_digest_at = CURRENT_TIMESTAMP,
           updated_at = CURRENT_TIMESTAMP`,
      )
        .bind(row.user_id, row.digest_frequency)
        .run();
    }

Comment on lines +139 to +141
to={`/watch/${encodeURIComponent(it.video_id)}`}
onClick={() => setOpen(false)}
style={{ display: 'flex', gap: 8, alignItems: 'center', textDecoration: 'none' }}
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.

medium

For better accessibility, links within a menu should explicitly have the role="menuitem" attribute. Additionally, consider adding role="none" to the parent <ul> and <li> elements to ensure screen readers treat the dropdown as a flat list of menu actions.

                  <Link
                    to={`/watch/${encodeURIComponent(it.video_id)}`}
                    onClick={() => setOpen(false)}
                    role="menuitem"
                    style={{ display: 'flex', gap: 8, alignItems: 'center', textDecoration: 'none' }}
                  >

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Adds an in-app bell with unread badge backed by the existing
subscription_inbox fan-out (ALO-156), plus a daily/weekly Resend email
digest of new uploads from a user's subscriptions. Digest cadence is a
per-user preference (default weekly, opt-out via 'off') exposed in
account settings. The cron sweep runs in the existing scheduled handler;
delivery is fail-open so a missing RESEND_API_KEY just skips the send.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@aloewright aloewright force-pushed the conductor/alo-157-notifications-in-app-email-digest branch from 886c445 to d03b059 Compare May 8, 2026 22:50
@ecc-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented May 8, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

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

Labels

conductor Conductor-managed PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants