Files
civitai__civitai/docs/features/notifications.md
T
Manuel Emilio Urena 169e918a8c feat(account): finish the /user/account redesign behind accountSettingsV2 (#4727)
## What

Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`).

**OFF serves the legacy single-column page byte-identically.** See "Rollout" below.

## Panes

| Pane | Change |
|---|---|
| Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile |
| Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped |
| Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features |
| Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened |
| Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers |
| Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config |
| Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading |
| Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last |

## Decisions worth a reviewer's attention

- **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field.
- **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead.
- **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL.
- **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count.
- **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read).
- **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork.
- **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported.
- **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`.

## Verification

Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79).

`SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it.

Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program.

## Not in this PR

- `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately.

## Rollout

`accountSettingsV2` → Flipt key `account-settings-v2`.

`availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
2026-09-09 16:08:19 -04:00

6.3 KiB

Notifications System

How notifications are produced, fanned out, read and displayed.

Where things live

The notification domain was moved out of the monolith (and out of the old external notification-server repo) into an in-repo app plus a shared package. Three places now split the work, and "which of the three owns this?" is the first question to answer for any change:

Location Owns
apps/notifications/ The notification database (sole owner), the settings opt-out filter on the single-row create path (the bulk path does not filter — see User settings), the fan-out poll worker, the read/count/mark queries, the per-user unread cache, and signal emission
packages/civitai-notifications/ The zod contracts, NotificationCategory and the signal constants, and the HTTP client — the single source of truth shared by every producer
src/server/notifications/ Only the per-feature processors (prepareQuery/prepareMessage), the detail-fetchers/ main-DB enrichment, and the configured client instance in client.ts

The external notification-server repo is retired.

Database

The monolith cannot reach the notification database. Only UserNotificationSettings is in the main Prisma schema. Notification, UserNotification and PendingNotification live in a physically separate database reached exclusively by apps/notifications, over its own pool (NOTIFICATION_DB_URL). Those env vars are optional in the monolith precisely because it no longer connects.

If you need notification rows, add an endpoint to apps/notifications — do not add a Prisma model.

Key fields

  • type — notification type (e.g. model-download-milestone)
  • category — grouping, see below
  • details — JSON payload
  • key — dedupe identifier
  • dedupeKey / debounceSeconds — first-class since the extraction; a cross-schema refine forbids combining them

Categories

Defined once in packages/civitai-notifications/src/constants.ts and re-exported from src/server/common/enums.ts. It is a const tuple plus a derived union, not a TypeScript enum, so import it rather than restating the members — the list has grown before and will again.

Processors

Processors live in src/server/notifications/*.notifications.ts, are built with createNotificationProcessor() from base.notifications.ts, and are collected in the utils.notifications.ts registry. Each defines query preparation, message formatting, and its category/settings.

The registry is large and covers far more than the obvious feature areas — read it rather than assuming a notification type doesn't exist yet.

Processors marked "Moveable" are ones that could become on-demand creation instead of job-based. Only genuinely time-based notifications — milestones, hourly/daily aggregations — need to stay in the send-notifications job.

Creating a notification

createNotification() in src/server/services/notification.service.ts is still the entry point, but its contract changed with the extraction: it is now an HTTP call to apps/notifications that never throws on transport failure. Client errors are swallowed and logged centrally.

That means a caller cannot treat a successful return as proof the notification was stored, and must not put it inside a transaction expecting rollback semantics. The same applies to markNotificationsRead.

Client

Component Responsibility
NotificationBell.tsx Unread indicator; opens the drawer; hides on notification pages
NotificationsDrawer.tsx Mantine Drawer shell only — delegates to NotificationsComposed
NotificationsComposed.tsx The real behavior: infinite scroll, category filtering, mark-as-read
NotificationList.tsx Presentational render only; takes an onItemClick prop

Changes to list behavior almost always belong in NotificationsComposed.tsx, not in the drawer or the list.

Realtime delivery is via Signals — the fan-out worker POSTs per affected user, and notifications.utils.ts subscribes.

User settings

src/components/Account/NotificationsCard.tsx (legacy page) and src/components/Account/NotificationsPane.tsx (the accountSettingsV2 pane) — both, until the flag is retired. Settings are stored in UserNotificationSettings (the one table still in the main schema); a row means opted out, except for optIn types — see NotificationProcessor.optIn in base.notifications.ts.

Only the single-row producer path filters on them. createNotification (apps/notifications/src/lib/server/create.ts) drops opted-out recipients before queueing. createNotificationsBulk (operations.ts) — the path every send-notifications processor takes — receives pre-resolved recipients and applies no filter. So a job-based processor must write its own clause:

WHERE NOT EXISTS (SELECT 1 FROM "UserNotificationSettings" WHERE "userId" = <recipient> AND type = '<type>')

Omit it and the toggle renders, saves, and does nothing. src/server/notifications/__tests__/notification-settings-polarity.test.ts is the guard, and it runs in pnpm run test:lint-rules. Its file lists name NotificationsCard.tsx only — the accountSettingsV2 pane is not yet covered. Its KNOWN_INERT list is empty and pinned, so a new inert type fails there rather than shipping unmuteable.

Caching

Unread counts are cached per user in Redis, keyed by category, by apps/notifications/src/lib/server/cache.ts. The monolith's old notification-cache.ts was deleted in the extraction.

API

tRPC routes in src/server/routers/notification.router.ts — three procedures, all scope-gated:

  • getAllByUser — paginated notifications
  • markRead — mark as read
  • updateUserSettings — notification preferences

Counts do not come from this router. The unread count is trpc.user.checkNotifications.

Debugging

  • Missing notifications — check the user's settings opt-out and the dedupe key. Remember create is best-effort, so a silent failure is logged, not thrown.
  • Duplicates — verify key generation, and whether dedupeKey/debounceSeconds are being combined.
  • Counts wrong — the cache is in apps/notifications, not this codebase.
  • Nothing fanning out — the worker is env-gated and defaults off; confirm it's enabled in the environment you're looking at before assuming a code bug.