Skip to content

feat(notifications): F05 in-app notification center — follow/like/comment fan-out, flat notify* - #124

Merged
Ardakilic merged 4 commits into
mainfrom
feat/f05-in-app-notifications
Aug 2, 2026
Merged

feat(notifications): F05 in-app notification center — follow/like/comment fan-out, flat notify*#124
Ardakilic merged 4 commits into
mainfrom
feat/f05-in-app-notifications

Conversation

@Ardakilic

@Ardakilic Ardakilic commented Aug 2, 2026

Copy link
Copy Markdown
Owner

F05 In-App Notification Center

Summary

Implements the OpenSpec change f05-in-app-notifications to extend BrewForm's notification fan-out beyond mentions (F04) to follow / like / comment types, and flattens the preference namespace to be channel-agnostic — the emailNotifications nest is gone; every flag now gates BOTH the in-app notification record AND the email.

Diff size: 36 files changed, +1578 / -242, 2 new files (migration + snapshot).

What Changes

1. Schema + migration

  • Enum: notificationTypeEnum extended with follow / like / comment (was mention-only). badge / system deferred (no call sites — YAGNI).
  • Columns: 5 user_preferences notification columns renamed with the notify_ prefix via Drizzle data-preserving RENAME COLUMN (no data loss):
    • new_followernotify_new_follower
    • recipe_likednotify_recipe_liked
    • recipe_commentednotify_recipe_commented
    • followed_user_postednotify_followed_user_posted
    • mentioned_in_commentnotify_mentioned_in_comment
  • Migration: packages/db/drizzle/0012_f05_notifications.sql (hand-written because Drizzle Kit 0.31's interactive rename prompt requires a TTY — --custom flag used as the non-interactive pivot; the snapshot JSON was manually aligned to keep future db-generate runs clean).

2. Shared schema flatten

  • UserPreferencesSchema (input), SelfPreferencesSchema (GET /me), UserPreferencesOutputSchema (GET /preferences), and UserPreferences interface all flattened to 5 top-level notify* boolean fields — request and response now identical (F04 asymmetry gone).
  • The F04 latent bug (notifyMentionedInComment was missing from /me and UserPreferences interface) is fixed structurally by the flatten — adding a flat field is one line, whereas the nest required re-listing every field at every projection site.
  • i18n en.json + tr.json (parity required): added notifications.follow / like / comment / all / unread; removed obsolete settings.emailNotifications key.

3. API fan-out

  • 3 new fan-out creators in apps/api/src/modules/notification/service.ts: createFollowNotification, createLikeNotification, createCommentNotification. Each mirrors the F04 createMentionNotifications pattern (load recipient prefs via findNotifyTarget → drop self-action → gate on the matching notify* preference flag → insert notification record → send email via the existing notify* helper → catch-log-continue per failure).
  • findNotifyTarget(userId) added to apps/api/src/modules/notification/model.ts (single-recipient loader, parallel to findMentionTargets). findMentionTargets projection renamed mentionedInComment → notifyMentionedInComment.
  • deps proxy extended to include notifyNewFollower, notifyRecipeLiked, notifyRecipeCommented so unit tests can stub all three new email side-effects alongside the existing notifyMentioned.
  • Wired fan-out at 3 call sites (fire-and-forget with .catch(err => logger.error(...)) — caller never blocks on notification):
    • follow/service.ts:followUser — after notifyNewFollower (record for followed user)
    • recipe/service.ts:toggleLike — inside the result.liked && recipe.authorId !== userId IIFE (record for recipe author)
    • comment/service.ts:runCommentNotificationSideEffects — inside the recipe.authorId !== userId block colocated with notifyRecipeCommented (record for recipe author — distinct from the existing createMentionNotifications which targets @mentioned users on the same comment)
  • preference/index.ts:PATCH /preferences flatten shrinks from if (body.emailNotifications !== undefined) { flatData.X = body.emailNotifications.X; ... } to 5 direct per-field copies.
  • user/model.ts:findById projection flattens — preferences object now exposes 5 notify* fields directly (no nested emailNotifications).
  • utils/notify/index.ts — 5 helper gate lines renamed from recipient.prefs.X === false to recipient.prefs.notifyX === false.

4. Web

  • SettingsPage.tsx: 5 toggles + toUserPreferences() adapter + savePreferences() PATCH payload all flattened to read/write prefs.notifyX directly; section header switched from settings.emailNotifications to the existing settings.notifications key.
  • NotificationItem.tsx: per-type switch on notification.type (mention / follow / like / comment with fallback to mentionGeneric for forward-compat with future enum additions); per-type link target (follow → /u/{actor}, comment → /recipes/{slug}#{commentId}, mention/like → /recipes/{slug}); metadata interface widened to include optional followerUsername.
  • NotificationListPage.tsx: All / Unread filter toggle (URL-driven via ?unreadOnly=true, loader re-fetches on filter change). Filter pills labeled with the new notifications.all / notifications.unread i18n keys.

5. Tests

All new code is exercised:

  • Shared: UserPreferencesSchema flat shape + default-true + legacy-strip behavior; SelfUserOutputSchema with notifyMentionedInComment (F04 latent bug regression guard); UserPreferencesOutputSchema 5-field round-trip + missing-notifyMentionedInComment rejection; PBT arb regenerated with the flat shape.
  • API notification/service.test.ts: 3 new describe suites (one per new creator) × 7 it blocks each (opt-out skip, missing-prefs-null opted-in, self-skip, target-not-found, happy-path record+email shapes, email-throws logged-and-continue, model.create-throws logged-and-continue with email still firing via independent try/catch). Existing createMentionNotifications suite updated to notifyMentionedInComment.
  • API notification/model.test.ts: findNotifyTarget suite (5 DB-backed tests: prefs joined, prefs-null row, missing user, soft-deleted, banned). Existing findMentionTargets test's mentionedInComment references renamed to notify_mentioned_in_comment.
  • API preference + user tests: findById 5-flag assertion incl. notifyMentionedInComment (F04 latent bug regression guard); preference/model.test.ts adds notifyMentionedInComment round-trip (F05 column rename regression guard).
  • API follow / recipe / comment: DB-backed fan-out tests asserting the new notification records are created on the right paths + idempotent for repeat events + skipped for self-actions; comment test extended with stubbing for the new createCommentNotification deps slot so F04 mention tests don't leak.
  • API openapi.coverage.test.ts: PATCH /preferences OpenAPI metadata still passes (no orphan tags, request body flat shape auto-generated via jsonRequestBody(UserPreferencesSchema); all 19 in-scope routes documented).
  • Web: NotificationItem.test.tsx adds follow / like / comment / unknown-type rendering tests + link target assertions; NotificationListPage.test.tsx adds All/Unread filter tests; SettingsPage.test.tsx 5-toggle flat round-trip.

Migration notes

  • DB columns renamed via ALTER TABLE ... RENAME COLUMN (data-preserving, no drop+create). Existing user_preferences rows carry over their true / false values unchanged.
  • Self-hosted pre-v1 single-repo client; no API versioning, no deprecation period, no legacy emailNotifications request body alias (Zod default strips unknown keys, so legacy clients sending the nested shape silently persist notify* = true defaults — visibility behavior change documented in the spec).

Non-goals

  • badge / system notification types (deferred YAGNI — no fan-out call sites today).
  • Real-time push (SSE / WebSocket / polling beyond the existing window-focus refetch).
  • Separate in-app-vs-email preference columns per type (one flag gates both — proven by F04 notifyMentionedInComment).
  • notifyFollowersOfNewRecipe in-app record (no matching enum type — email-only remains).
  • Notification idempotency (re-like → re-notify, matching the existing email semantics).

Verification

Gate Status
make fmt ✅ Applied
make check (api + web + db + shared) ✅ Pass — 302 files
make lint ✅ Pass — 661 files
make test-shared ✅ 136 passed
make test-api ✅ 316 passed
make test-web (Vitest) ✅ 1289 passed
OpenAPI coverage test (/api/v1/openapi.json introspection) ✅ All 19 in-scope routes documented, zero orphan tags

OpenSpec change

  • Change directory: openspec/changes/f05-in-app-notifications/
  • Planning artifacts: proposal.md, design.md, tasks.md, specs/notifications/spec.md, specs/api-type-safety/spec.md — all isComplete: true.
  • Source plan docs: plans/F05-in-app-notifications.md (prepended with ship banner pointing at the OpenSpec change as source of truth), plans/F29-weekly-email-digest.md (prepended with forward-compat note so future implementers don't blindly follow the pre-F05 nested-shape suggestion).

Caveats

  • Migration generation workaround: Drizzle Kit 0.31's db-generate requires a TTY for column rename disambiguation prompts; this non-interactive environment errors out ("Interactive prompts require a TTY terminal"). Pivot used: drizzle-kit generate --custom --name=f05_notifications creates an empty migration shell non-interactively, then the SQL is hand-written per the design's R17 template (5 RENAME COLUMN + 3 ALTER TYPE ADD VALUE). The snapshot JSON was manually updated to reflect post-migration column names so future db-generate runs won't re-prompt for the same renames.
  • Test DB reset: admin/model.test.ts:getTopUsers had accumulated diff-* user pollution from prior test runs (per AGENTS.md: "seed cannot remove stray rows left by API tests"). The test DB was dropped and re-provisioned before final verification — getTopUsers passes in isolation.

Summary by CodeRabbit

  • New Features

    • Added in-app notifications for follows, recipe likes, and comments.
    • Added dedicated notification messages and links to profiles, recipes, and comment locations.
    • Added All and Unread notification filters with URL-based state.
    • Flattened notification preferences into individual settings, including comment mentions.
  • Bug Fixes

    • Prevented self-actions and duplicate follows from generating inappropriate notifications.
    • Notification delivery failures no longer interrupt the underlying action.

…ment fan-out, flat notify* preference rename

Implements OpenSpec change f05-in-app-notifications.

- DB: extend notificationTypeEnum with follow/like/comment; rename 5
  user_preferences notification columns with notify_ prefix via
  data-preserving RENAME COLUMN. New migration 0012_f05_notifications
  (Drizzle Kit --custom workaround for non-interactive column-rename TTY
  prompt; snapshot manually aligned).
- Shared: flatten UserPreferencesSchema, SelfPreferencesSchema,
  UserPreferencesOutputSchema, UserPreferences interface to 5 flat
  notify* top-level fields. F04 latent notifyMentionedInComment omission
  fixed structurally by the flatten (one-line flatten vs re-list every
  nest field at every projection site).
- API: add 3 new fan-out creators (createFollowNotification,
  createLikeNotification, createCommentNotification) mirroring
  createMentionNotifications; new findNotifyTarget model loader;
  deps proxy extended. Wire at the 3 call sites fire-and-forget:
  follow/service.ts, recipe/service.ts:toggleLike,
  comment/service.ts:runCommentNotificationSideEffects.
  preference/index.ts + user/model.ts:findById flatten.
  utils/notify/index.ts 5 helper gate lines renamed.
- Web: SettingsPage 5 toggles + toUserPreferences adapter + savePreferences
  PATCH all flattened. NotificationItem per-type switch (mention/follow/
  like/comment + mentionGeneric fallback) with per-type link targets.
  NotificationListPage All/Unread filter via ?unreadOnly=true URL state.
- i18n: en + tr parity (5 new keys); remove obsolete settings.emailNotifications.
- Tests: shared schema + pbt; API notification service 3 new suites × 7
  scenarios each + findNotifyTarget DB-backed suite + preference+user +
  follow/recipe/comment DB-backed fan-out tests + openapi coverage test;
  web NotificationItem/NotificationListPage/SettingsPage tests.
- Plans: plans/F05-in-app-notifications.md prepended with ship banner;
  plans/F29-weekly-email-digest.md prepended with forward-compat note
  for the future implementer (don't add nested weeklyDigest, use flat
  notifyWeeklyDigest per the F05 pattern).

Verification: make fmt + make check (302 files) + make lint (661 files)
+ make test (api 316 + shared 136 + web 1289 — all green) + openapi
coverage test (19 in-scope routes, zero orphan tags).
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Ardakilic, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c1a09c9-fc65-416d-8f86-3c4fcd4eab96

📥 Commits

Reviewing files that changed from the base of the PR and between ffa9c41 and 1e5bb5f.

📒 Files selected for processing (1)
  • apps/api/src/modules/comment/service.test.ts
📝 Walkthrough

Walkthrough

The change adds persisted follow, like, and comment notifications, flattens notification preferences into notify* fields, updates API integrations, and adds web notification rendering with All/Unread filtering.

Changes

In-app notification expansion

Layer / File(s) Summary
Notification schema and shared contracts
packages/db/..., packages/shared/src/schemas/..., packages/shared/src/types/user.ts
Notification types now include follow, like, and comment values. Preference fields now use a flat notify* shape.
Preference propagation and compatibility updates
apps/api/src/modules/preference/..., apps/api/src/modules/user/..., apps/api/src/utils/notify/..., openspec/..., plans/...
API models, PATCH validation, delivery gates, tests, and implementation records use the flattened preference fields.
Notification target lookup and fan-out
apps/api/src/modules/notification/...
The notification model finds eligible recipients. The service creates follow, like, and comment records with preference checks, email delivery, and isolated failures.
Activity-service notification wiring
apps/api/src/modules/comment/..., apps/api/src/modules/follow/..., apps/api/src/modules/recipe/...
Comment, follow, and like actions asynchronously create persisted notifications for eligible external actions.
Notification rendering, filtering, and settings
apps/web/src/components/layout/..., apps/web/src/pages/notifications/..., apps/web/src/pages/settings/..., packages/shared/src/i18n/*
The web interface renders notification-specific links and text, supports All/Unread filters, and submits flat notification preferences.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant A
  participant B
  A->>B: interaction
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: F05 in-app notifications with follow, like, comment fan-out, and flattened notification preferences.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/f05-in-app-notifications

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.

…ness check was producing a spurious 0013 migration

When I used 'drizzle-kit generate --custom' (TTY-less workaround) for the F05
migration, I hand-edited the snapshot for the column renames but missed the
enum extension. CI's db:generate step then compared the on-disk schema
(4-value enum) against the snapshot (1-value enum) and produced a spurious
0013_quiet_paibok migration adding the 3 missing enum values — failing the
freshness check.

Fix: update meta/0012_snapshot.json public.notification_type enum values
from ['mention'] to ['mention','follow','like','comment']. Re-ran
'make db-generate' — outputs 'No schema changes, nothing to migrate 😴'.

Also extends AGENTS.md with the full --custom snapshot hygiene rule so
this failure category is documented: every schema change in a --custom
migration MUST be reflected in the manually-edited snapshot (renames,
enum values, columns, indexes, constraints), with db:generate's
'nothing to migrate 😴' output as the freshness gate.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/modules/follow/service.ts (1)

35-51: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remove the duplicate new-follower email fan-out.

createFollowNotification already checks notifyNewFollower, persists the follow record, and calls notifyNewFollower, so the direct notifyNewFollower(...) in apps/api/src/modules/follow/service.ts:38-41 sends the same email twice for one follow event. Keep the fan-out in createFollowNotification only, remove the direct import if unused, and await the single entry point in the fire-and-forget path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/follow/service.ts` around lines 35 - 51, Remove the
direct notifyNewFollower call from the async follow handler and delete its
import if unused; await createFollowNotification as the sole fan-out entry point
while preserving the existing error logging and fire-and-forget wrapper.
🧹 Nitpick comments (4)
apps/web/src/pages/notifications/NotificationListPage.test.tsx (1)

183-188: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove stale TODO comments describing an unlanded feature that already ships in this PR.

The comment states T38's All/Unread filter UI "has NOT landed yet," but NotificationListPage.tsx (lines 133-152, same PR) already renders the All / Unread buttons and the tests below pass against that implementation. Leaving this comment risks confusing future readers into thinking these tests are speculative.

♻️ Proposed cleanup
-  // ── F05 filter UI tests (T41) ──
-  // TODO: needs signature confirm — actual `notificationApi.list(page: number, unreadOnly?: boolean)`
-  // (positional, NOT the object form the design hint described). T38's All/Unread filter UI
-  // has NOT landed yet; these tests will only pass once buttons labeled 'All' / 'Unread'
-  // (via t('notifications.all') / t('notifications.unread')) render in the page header.
+  // ── F05 filter UI tests (T41) ──
+  // Covers the All/Unread filter buttons in NotificationListPage.tsx, which call
+  // `notificationApi.list(page, unreadOnly)` positionally.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/pages/notifications/NotificationListPage.test.tsx` around lines
183 - 188, Remove the stale TODO and explanatory comments above the F05 filter
UI tests in NotificationListPage.test.tsx, including the outdated claim that the
All/Unread filter UI has not landed; leave the tests and implementation
unchanged.
apps/web/src/components/layout/NotificationItem.tsx (1)

68-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the repeated interpolation-with-fallback pattern.

The like, comment, and mention cases repeat the same meta.recipeTitle ? t(key)...replace(...) : t('notifications.mentionGeneric')... shape. A small helper reduces duplication and keeps the three branches from drifting apart as more types are added.

♻️ Proposed refactor
+  function withRecipeTitleOrGeneric(key: string, extra: Record<string, string> = {}) {
+    if (!meta.recipeTitle) return t('notifications.mentionGeneric').replace('{username}', username);
+    let result = t(key).replace('{recipeTitle}', meta.recipeTitle);
+    for (const [k, v] of Object.entries(extra)) result = result.replace(`{${k}}`, v);
+    return result;
+  }
+
   const text = (() => {
     switch (notification.type) {
       case 'follow':
         return t('notifications.follow').replace('{actorUsername}', username);
       case 'like':
-        return meta.recipeTitle
-          ? t('notifications.like')
-            .replace('{actorUsername}', username)
-            .replace('{recipeTitle}', meta.recipeTitle)
-          : t('notifications.mentionGeneric').replace('{username}', username);
+        return withRecipeTitleOrGeneric('notifications.like', { actorUsername: username });
       case 'comment':
-        return meta.recipeTitle
-          ? t('notifications.comment')
-            .replace('{actorUsername}', username)
-            .replace('{recipeTitle}', meta.recipeTitle)
-          : t('notifications.mentionGeneric').replace('{username}', username);
+        return withRecipeTitleOrGeneric('notifications.comment', { actorUsername: username });
       case 'mention':
-        return meta.recipeTitle
-          ? t('notifications.mention')
-            .replace('{username}', username)
-            .replace('{recipeTitle}', meta.recipeTitle)
-          : t('notifications.mentionGeneric').replace('{username}', username);
+        return withRecipeTitleOrGeneric('notifications.mention', { username });
       default:
         return t('notifications.mentionGeneric').replace('{username}', username);
     }
   })();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/layout/NotificationItem.tsx` around lines 68 - 95,
Extract the repeated recipe-title interpolation and generic fallback logic from
the notification text switch into a small local helper, then have the like,
comment, and mention cases call it with their respective translation keys and
placeholders. Preserve the existing follow behavior, translation keys,
replacement values, and fallback behavior.
apps/api/src/modules/follow/service.test.ts (1)

67-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

makeUser and deleteUserWithBadges are duplicated.

Both helpers are identical to the versions in apps/api/src/modules/recipe/service.test.ts Lines 67-91. Move them to a shared test helper module and import them in both files. This keeps the badge-race retry logic in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/follow/service.test.ts` around lines 67 - 91, Move the
duplicated makeUser and deleteUserWithBadges helpers from the follow and recipe
service tests into a shared test-helper module, preserving their current
database insertion and badge-race retry behavior. Import and use the shared
helpers in both service.test.ts files, removing the local definitions.
apps/api/src/modules/notification/service.ts (1)

158-412: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting the shared fan-out body.

The three creators repeat the same nine steps: self-check, findNotifyTarget, preference gate, create in an inner try, email in an inner try, and the outer log-and-rethrow. Only the preference key, the record fields, and the email call differ. Extract a helper that accepts those three parts. This keeps future notification types consistent and removes about 180 duplicated lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/notification/service.ts` around lines 158 - 412, Extract
the shared fan-out workflow from createFollowNotification,
createLikeNotification, and createCommentNotification into a helper accepting
the preference key, notification record fields, and email callback. Preserve
each creator’s self-check, target lookup, preference gating, isolated
create/email errors, completion logging, and outer log-and-rethrow behavior,
then have the three public creators supply only their type-specific values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/modules/notification/service.test.ts`:
- Around line 380-490: Update the creator suites for createFollowNotification,
createLikeNotification, and createCommentNotification to assert email behavior
through their production call sites rather than assuming the creators own
delivery. Resolve duplicate ownership first, then add call-site-level assertions
that the combined follow, recipe, and comment flows send exactly one email,
while preserving the existing preference and failure coverage.

In `@apps/api/src/modules/notification/service.ts`:
- Around line 223-227: Email ownership is duplicated across creators and
callers, causing duplicate sends and bypassing preference gates. Make
notification creators the sole owners: keep the follow block in
apps/api/src/modules/notification/service.ts lines 223-227 and equivalent
creator blocks at lines 296-305 and 393-402, remove caller sends and correct
gating comments in apps/api/src/modules/comment/service.ts lines 164-183 and
apps/api/src/modules/recipe/service.ts lines 566-596. Update
apps/api/src/modules/notification/service.test.ts lines 380-701 to assert one
email per call site.

In `@apps/api/src/modules/preference/index.ts`:
- Around line 87-107: Update the PATCH validation flow around
UserPreferencesSchema to use a PATCH-only schema where notification booleans are
optional and have no defaults, then build flatData from only fields actually
present in the request. Ensure omitted preferences remain unchanged when
updating through the PATCH handler, including the fields copied by the flatData
construction.

In `@apps/web/src/pages/notifications/NotificationListPage.tsx`:
- Around line 133-152: Update the filter button group’s aria-label in the
notification list component to use a dedicated translation key describing
notification filtering, such as notifications.filterLabel, instead of
notifications.title. Add the corresponding translation entry in the project’s
existing notification translations.

In `@plans/F05-in-app-notifications.md`:
- Around line 3-6: Remove the standalone blank blockquote line between the
shipped banner and the implementation note in the plan content, keeping both
lines in one continuous blockquote so Markdownlint MD028 passes.

In `@plans/F29-weekly-email-digest.md`:
- Around line 3-5: Fix the MD028 violation in the blockquote at the start of the
document by replacing the blank line between the two quoted lines with a
blockquote continuation marker, joining them into one contiguous blockquote.

---

Outside diff comments:
In `@apps/api/src/modules/follow/service.ts`:
- Around line 35-51: Remove the direct notifyNewFollower call from the async
follow handler and delete its import if unused; await createFollowNotification
as the sole fan-out entry point while preserving the existing error logging and
fire-and-forget wrapper.

---

Nitpick comments:
In `@apps/api/src/modules/follow/service.test.ts`:
- Around line 67-91: Move the duplicated makeUser and deleteUserWithBadges
helpers from the follow and recipe service tests into a shared test-helper
module, preserving their current database insertion and badge-race retry
behavior. Import and use the shared helpers in both service.test.ts files,
removing the local definitions.

In `@apps/api/src/modules/notification/service.ts`:
- Around line 158-412: Extract the shared fan-out workflow from
createFollowNotification, createLikeNotification, and createCommentNotification
into a helper accepting the preference key, notification record fields, and
email callback. Preserve each creator’s self-check, target lookup, preference
gating, isolated create/email errors, completion logging, and outer
log-and-rethrow behavior, then have the three public creators supply only their
type-specific values.

In `@apps/web/src/components/layout/NotificationItem.tsx`:
- Around line 68-95: Extract the repeated recipe-title interpolation and generic
fallback logic from the notification text switch into a small local helper, then
have the like, comment, and mention cases call it with their respective
translation keys and placeholders. Preserve the existing follow behavior,
translation keys, replacement values, and fallback behavior.

In `@apps/web/src/pages/notifications/NotificationListPage.test.tsx`:
- Around line 183-188: Remove the stale TODO and explanatory comments above the
F05 filter UI tests in NotificationListPage.test.tsx, including the outdated
claim that the All/Unread filter UI has not landed; leave the tests and
implementation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6bf88442-f430-4e40-965f-a824258c0fab

📥 Commits

Reviewing files that changed from the base of the PR and between 17669ef and 32f793a.

📒 Files selected for processing (38)
  • apps/api/src/modules/comment/service.test.ts
  • apps/api/src/modules/comment/service.ts
  • apps/api/src/modules/follow/service.test.ts
  • apps/api/src/modules/follow/service.ts
  • apps/api/src/modules/notification/model.test.ts
  • apps/api/src/modules/notification/model.ts
  • apps/api/src/modules/notification/service.test.ts
  • apps/api/src/modules/notification/service.ts
  • apps/api/src/modules/preference/index.ts
  • apps/api/src/modules/preference/model.test.ts
  • apps/api/src/modules/recipe/service.test.ts
  • apps/api/src/modules/recipe/service.ts
  • apps/api/src/modules/user/model.test.ts
  • apps/api/src/modules/user/model.ts
  • apps/api/src/utils/notify/index.ts
  • apps/web/src/components/layout/NotificationItem.test.tsx
  • apps/web/src/components/layout/NotificationItem.tsx
  • apps/web/src/pages/notifications/NotificationListPage.test.tsx
  • apps/web/src/pages/notifications/NotificationListPage.tsx
  • apps/web/src/pages/settings/SettingsPage.test.tsx
  • apps/web/src/pages/settings/SettingsPage.tsx
  • openspec/changes/f05-in-app-notifications/tasks.md
  • packages/db/drizzle/0012_f05_notifications.sql
  • packages/db/drizzle/meta/0012_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema.ts
  • packages/shared/src/i18n/en.json
  • packages/shared/src/i18n/tr.json
  • packages/shared/src/schemas/responses/output-schema-acceptance.pbt.test.ts
  • packages/shared/src/schemas/responses/preference.test.ts
  • packages/shared/src/schemas/responses/preference.ts
  • packages/shared/src/schemas/responses/user.test.ts
  • packages/shared/src/schemas/responses/user.ts
  • packages/shared/src/schemas/user.test.ts
  • packages/shared/src/schemas/user.ts
  • packages/shared/src/types/user.ts
  • plans/F05-in-app-notifications.md
  • plans/F29-weekly-email-digest.md

Comment thread apps/api/src/modules/notification/service.test.ts
Comment on lines +223 to +227
try {
await deps.notifyNewFollower({ followingId, followerUsername });
} catch (err) {
logger.error({ err, followerId, followingId }, 'follow email failed');
}

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.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Follow, like, and comment emails are each sent twice. The new fan-out creators send the same email that the existing call sites already send. No call site removed its original send, so every follow, like, and comment produces two identical emails. The same duplication breaks the preference gate: the caller-side send runs before any notify* lookup, so an opted-out recipient still receives one email despite the "single flag gates both" claim in the docs. Pick one owner for each email channel, then apply it at every site.

  • apps/api/src/modules/notification/service.ts#L223-L227: decide email ownership here. If the creator owns the email, keep this block and the equivalent blocks at Lines 296-305 and Lines 393-402. If the caller owns it, delete all three blocks and drop notifyNewFollower, notifyRecipeLiked, and notifyRecipeCommented from deps.
  • apps/api/src/modules/comment/service.ts#L164-L183: remove the await deps.notifyRecipeCommented(...) call at Lines 166-171 if the creator owns the email, and correct the gating claim in the comment at Lines 162-163.
  • apps/api/src/modules/recipe/service.ts#L566-L596: remove the await notifyRecipeLiked(...) call at Lines 581-586 if the creator owns the email, and correct the gating claim in the doc at Lines 568-569.
  • apps/api/src/modules/notification/service.test.ts#L380-L701: after the ownership change, assert the email count at the call-site level so a reintroduced duplicate fails a test.
📍 Affects 4 files
  • apps/api/src/modules/notification/service.ts#L223-L227 (this comment)
  • apps/api/src/modules/comment/service.ts#L164-L183
  • apps/api/src/modules/recipe/service.ts#L566-L596
  • apps/api/src/modules/notification/service.test.ts#L380-L701
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/notification/service.ts` around lines 223 - 227, Email
ownership is duplicated across creators and callers, causing duplicate sends and
bypassing preference gates. Make notification creators the sole owners: keep the
follow block in apps/api/src/modules/notification/service.ts lines 223-227 and
equivalent creator blocks at lines 296-305 and 393-402, remove caller sends and
correct gating comments in apps/api/src/modules/comment/service.ts lines 164-183
and apps/api/src/modules/recipe/service.ts lines 566-596. Update
apps/api/src/modules/notification/service.test.ts lines 380-701 to assert one
email per call site.

Comment thread apps/api/src/modules/preference/index.ts Outdated
Comment thread apps/web/src/pages/notifications/NotificationListPage.tsx
Comment thread plans/F05-in-app-notifications.md Outdated
Comment on lines +3 to +6
> **✅ Shipped via OpenSpec change `f05-in-app-notifications` (2026-08-02).**
>
> The implemented shape is **Option C — flat top-level `notify*` schema fields + `notify_`-prefixed DB columns** (the 11-point What Changes in the OpenSpec `proposal.md` is the source of truth; the validation preface below is preserved as the historical 2026-07-13 audit context).

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the blank line inside the blockquote.

The standalone > line creates a blank line inside the blockquote. Markdownlint MD028 flags this pattern. Keep the banner and the implementation note in one blockquote without the empty quoted line.

Proposed fix
 > **✅ Shipped via OpenSpec change `f05-in-app-notifications` (2026-08-02).**
->
 > The implemented shape is **Option C — flat top-level `notify*` schema fields + `notify_`-prefixed DB columns** (the 11-point What Changes in the OpenSpec `proposal.md` is the source of truth; the validation preface below is preserved as the historical 2026-07-13 audit context).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
> **✅ Shipped via OpenSpec change `f05-in-app-notifications` (2026-08-02).**
>
> The implemented shape is **Option C — flat top-level `notify*` schema fields + `notify_`-prefixed DB columns** (the 11-point What Changes in the OpenSpec `proposal.md` is the source of truth; the validation preface below is preserved as the historical 2026-07-13 audit context).
> **✅ Shipped via OpenSpec change `f05-in-app-notifications` (2026-08-02).**
> The implemented shape is **Option C — flat top-level `notify*` schema fields + `notify_`-prefixed DB columns** (the 11-point What Changes in the OpenSpec `proposal.md` is the source of truth; the validation preface below is preserved as the historical 2026-07-13 audit context).
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 6-6: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/F05-in-app-notifications.md` around lines 3 - 6, Remove the standalone
blank blockquote line between the shipped banner and the implementation note in
the plan content, keeping both lines in one continuous blockquote so
Markdownlint MD028 passes.

Source: Linters/SAST tools

Comment thread plans/F29-weekly-email-digest.md
…e schema, a11y/i18n nits

Email ownership ( Inline 1+2, Outside 1):
- follow/recipe/comment services no longer call notifyNewFollower /
  notifyRecipeLiked / notifyRecipeCommented directly — the notification
  creators (createFollowNotification / createLikeNotification /
  createCommentNotification) are the SOLE owners of both the in-app record
  AND the email. The previous double-send bypassed the preference gate and
  sent two emails per event.
- Removed direct notify* imports and deps entries from the three callers;
  updated creator docblocks to state sole ownership.
- comment/service.test.ts: replaced notifyCalls recorder with commentCalls
  (the creator stub); reworked the rejection-resilience test to stub
  createCommentNotification instead of notifyRecipeCommented.
- follow/service.test.ts: refreshed the DB-integration comment block.

PATCH preference schema (Inline 4):
- Added UserPreferencesPatchSchema (all fields .optional(), no .default())
  in schemas/user.ts. Zod v4 .partial() preserves .default(), which would
  still fill omitted booleans to true and overwrite stored values — the
  patch schema is rebuilt with .optional() to avoid that.
- preference/index.ts PATCH route now validates with UserPreferencesPatchSchema;
  omitted fields parse to undefined and the flatData !== undefined guards
  skip them, so omitted preferences stay unchanged.
- Added 4 unit tests for the patch schema (omitted → undefined, present,
  empty, invalid enum).

a11y / i18n (Inline 5):
- NotificationListPage filter group aria-label now uses
  notifications.filterLabel instead of notifications.title.
- Added notifications.filterLabel to en + tr i18n and the web test mock maps.

plans MD028 (Inline 6+7):
- F05 + F29 plans: joined the two separated blockquotes into one
  contiguous blockquote via > continuation lines.

stale TODOs (Nitpick 4):
- NotificationListPage.test.tsx: removed the outdated 'F05 filter UI has
  NOT landed' TODO block and the 'T38 may revert' TODO.

Skipped nitpicks (ponytail: abstraction for ~3 sites is scaffolding):
- shared test helpers (makeUser / deleteUserWithBadges duplicated 2x)
- extract fan-out helper from 3 creators
- extract NotificationItem text interpolation helper

Validation: make check (302 files), make lint (661 files), make fmt,
shared 137 passed, api 316 passed, web 1289 passed.
@Ardakilic

Copy link
Copy Markdown
Owner Author

Review fixes pushed (ffa9c41)

Addressed the inline + outside-diff findings; skipped 3 nitpicks per YAGNI.

Fixed

Email ownership duplication (Inline 1+2, Outside 1) — real bug: follow/recipe/comment call sites were calling notifyNewFollower / notifyRecipeLiked / notifyRecipeCommented directly AND delegating to the creators, which called them again. The direct caller sends bypassed the preference gate and double-sent. Creators are now the sole owners of both the in-app record and the email:

  • Removed direct notify* calls + imports + deps entries from follow/service.ts, recipe/service.ts, comment/service.ts.
  • Updated creator docblocks to state sole ownership.
  • comment/service.test.ts: replaced the notifyCalls recorder with commentCalls (the creator stub); reworked the rejection-resilience test to stub createCommentNotification instead of notifyRecipeCommented.
  • follow/service.test.ts: refreshed the DB-integration comment block.

PATCH preference schema (Inline 4)UserPreferencesSchema has .default(true) on every notify boolean, so a PATCH with {theme:'dark'} parsed to all-defaults and the flatData builder copied every field, silently overwriting omitted preferences. Added UserPreferencesPatchSchema (all fields .optional(), no .default() — Zod v4 .partial() preserves .default(), so it had to be rebuilt with .optional()). The PATCH route now validates with it; omitted fields parse to undefined and the !== undefined guards skip them. 4 new unit tests cover the omitted/present/empty/invalid cases.

aria-label (Inline 5) — filter group now uses notifications.filterLabel (en: "Filter notifications", tr: "Bildirimleri filtrele"); added to i18n + test mock maps.

plans MD028 (Inline 6+7) — F05 + F29 blockquotes joined into single contiguous blocks via > continuation.

stale TODOs (Nitpick 4) — removed the outdated "F05 filter UI has NOT landed" block and the "T38 may revert" TODO from NotificationListPage.test.tsx.

Skipped (nitpicks — ponytail: abstraction for ~3 sites is scaffolding)

  • Shared test helpers (makeUser / deleteUserWithBadges duplicated in follow + recipe test files) — 2 consumers, stable; re-extract if a 3rd lands.
  • Extract fan-out helper from the 3 creators — share structure but differ in self-check var, prefs key, record fields, email params; the mirror pattern is already documented. Re-extract if a 4th notification type lands.
  • Extract NotificationItem text interpolation helper — 3 cases share a 4-line recipeTitle ? interpolate : fallback pattern; the inline switch is arguably more readable.

Validation

make check (302 files) ✓ · make lint (661 files) ✓ · make fmt ✓ · shared 137 passed · api 316 passed · web 1289 passed.

@Ardakilic

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
apps/api/src/modules/comment/service.test.ts (1)

737-738: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Assert the complete createCommentNotification payload.

The contract in apps/api/src/modules/notification/service.ts, Lines 332-425, consumes commenterId, commenterUsername, recipeAuthorId, recipeId, recipeSlug, recipeTitle, and commentId. These assertions check only recipeAuthorId.

A wrong actor, recipe, slug, title, or comment ID can pass these tests while creating an incorrect notification record or email. Assert the full captured object in both cases.

Also applies to: 820-821

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/modules/comment/service.test.ts` around lines 737 - 738, Update
both notification assertions in the comment service tests to verify the complete
captured createCommentNotification payload, including commenterId,
commenterUsername, recipeAuthorId, recipeId, recipeSlug, recipeTitle, and
commentId. Replace the single recipeAuthorId check in each case with an
object-level assertion matching the expected values for that test scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/modules/comment/service.test.ts`:
- Around line 737-738: Update both notification assertions in the comment
service tests to verify the complete captured createCommentNotification payload,
including commenterId, commenterUsername, recipeAuthorId, recipeId, recipeSlug,
recipeTitle, and commentId. Replace the single recipeAuthorId check in each case
with an object-level assertion matching the expected values for that test
scenario.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8afbf37-ed49-46e5-8884-d0d17233b597

📥 Commits

Reviewing files that changed from the base of the PR and between 32f793a and ffa9c41.

📒 Files selected for processing (19)
  • AGENTS.md
  • apps/api/src/modules/comment/service.test.ts
  • apps/api/src/modules/comment/service.ts
  • apps/api/src/modules/follow/service.test.ts
  • apps/api/src/modules/follow/service.ts
  • apps/api/src/modules/notification/service.ts
  • apps/api/src/modules/preference/index.ts
  • apps/api/src/modules/recipe/service.ts
  • apps/web/src/pages/notifications/NotificationListPage.test.tsx
  • apps/web/src/pages/notifications/NotificationListPage.tsx
  • openspec/changes/f05-in-app-notifications/tasks.md
  • packages/db/drizzle/meta/0012_snapshot.json
  • packages/shared/src/i18n/en.json
  • packages/shared/src/i18n/tr.json
  • packages/shared/src/schemas/index.ts
  • packages/shared/src/schemas/user.test.ts
  • packages/shared/src/schemas/user.ts
  • plans/F05-in-app-notifications.md
  • plans/F29-weekly-email-digest.md
🚧 Files skipped from review as they are similar to previous changes (11)
  • plans/F29-weekly-email-digest.md
  • apps/web/src/pages/notifications/NotificationListPage.tsx
  • packages/shared/src/i18n/tr.json
  • packages/shared/src/i18n/en.json
  • apps/web/src/pages/notifications/NotificationListPage.test.tsx
  • apps/api/src/modules/follow/service.test.ts
  • openspec/changes/f05-in-app-notifications/tasks.md
  • plans/F05-in-app-notifications.md
  • apps/api/src/modules/comment/service.ts
  • apps/api/src/modules/notification/service.ts
  • apps/api/src/modules/recipe/service.ts

…alls

Replace the two single-field recipeAuthorId checks (lines ~738, ~821) with
object-level toEqual assertions matching the complete captured
createCommentNotification payload (commenterId, commenterUsername,
recipeAuthorId, recipeId, recipeSlug, recipeTitle, commentId), mirroring the
full-payload pattern already used in the 'invoked when commenter != recipe
author' test below.
@Ardakilic
Ardakilic merged commit c38bd0d into main Aug 2, 2026
7 checks passed
@Ardakilic
Ardakilic deleted the feat/f05-in-app-notifications branch August 2, 2026 14:42
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.

1 participant