Skip to content

User Preferences for Notification Channels#3657

Open
CarsonF wants to merge 11 commits intodevelopfrom
notifications/config
Open

User Preferences for Notification Channels#3657
CarsonF wants to merge 11 commits intodevelopfrom
notifications/config

Conversation

@CarsonF
Copy link
Contributor

@CarsonF CarsonF commented Feb 20, 2026

This adds functionality to allow users to opt in & out of certain notifications by each delivery channel. Currently there's still only App delivery channel. But Email is next.

@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

Adds a notification preferences subsystem: DB migration and EdgeDB type, GraphQL DTOs/inputs/resolver, Neo4j and EdgeQL repositories, Nest module, service integration into notification delivery, enums/type-registration plumbing, and module augmentations for concrete notifications.

Changes

Cohort / File(s) Summary
Database schema
dbschema/migrations/00030-m1ded7e.edgeql, dbschema/notifications.gel
Adds Notification::Preference EdgeDB type with required user link (ON TARGET DELETE DELETE SOURCE), required notificationType, channel, enabled, and an exclusive constraint on (user, notificationType, channel).
Notification DTOs & enums
src/components/notifications/dto/notification-channel.enum.ts, src/components/notifications/dto/notification-type.enum.ts, src/components/notifications/dto/index.ts
Adds NotificationChannel and ChannelAvailability enums and helpers, a dynamic NotificationMap interface and registry (NotificationTypeEntries), lazy NotificationType enum construction, and re-exports.
Module augmentations for concrete notifications
src/components/comments/mention-notification/comment-via-mention-notification.dto.ts, src/components/notification-system/system-notification.dto.ts
Adds TypeScript module augmentations that extend NotificationMap to register CommentViaMention and System notification types for type-level mapping.
Notification core — service & strategy
src/components/notifications/notification.service.ts, src/components/notifications/notification.strategy.ts
Introduces registries keyed by class and name, new strategy APIs (getLabel, getDescription, channelAvailabilities), decorator registration into NotificationTypeEntries, injects preferences service, adds deliverToAppChannel and per-user channel override handling in create flow.
Notification core — repository & hydration
src/components/notifications/notification.repository.ts, src/components/notifications/notification.gel.repository.ts
NotificationRepository.create branches on App channel availability and applies per-user preference filters; hydration now iterates over strategiesByClassType for concrete assembly.
Preferences GraphQL DTOs
src/components/notifications/preferences/notification-preference.dto.ts
Adds NotificationChannelPreference and NotificationPreference object types plus UpdateNotificationChannelOverride and UpdateNotificationPreference input types for GraphQL (decorators, descriptions, MarkdownScalar usage).
Preferences repositories (Neo4j & EdgeQL)
src/components/notifications/preferences/notification-preferences.repository.ts, src/components/notifications/preferences/notification-preferences.gel.repository.ts
Adds repositories to fetch/persist per-user overrides: getOverridesForUsers() and saveOverrides() with upsert/delete semantics and typed row/item interfaces.
Preferences service, resolver, module
src/components/notifications/preferences/notification-preferences.service.ts, src/components/notifications/preferences/notification-preferences.resolver.ts, src/components/notifications/preferences/notification-preferences.module.ts
New service to merge defaults and user overrides (getPreferences, updatePreferences, getOverridesMap), resolver for queries/mutations and channels field resolver, and Nest module wiring (providers, splitDb provider, exports).
Public API & exports
src/components/notifications/index.ts, src/components/notifications/dto/index.ts
Re-exports NotificationType and type NotificationMap, plus new channel/type enums and DTO exports.
Minor wiring changes
src/components/notifications/..., src/components/comments/...
Adds NotificationPreferencesModule to NotificationModule imports; small iterations/import updates (strategy map → strategiesByClassType) and module import/export adjustments.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Refactor splitDb #3654: Changes to splitDb provider shape and repository wiring that intersect how the new NotificationPreferencesModule configures splitDb.
  • notificationAdded subscription #3586: Prior changes to notification strategy registration, repositories, and broadcasting flows that overlap with the strategy registry and repository updates in this PR.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description explains the core feature but lacks required template sections (Monday task, ready for review checklist, etc.) and missing test/review details. Complete the description template by adding the Monday task link or reason, confirming test coverage, and adding reviewers to the PR.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'User Preferences for Notification Channels' directly and concisely describes the main change—adding per-channel notification preference functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch notifications/config

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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
src/components/notifications/notification.module.ts (1)

13-13: Add forwardRef on both sides of the circular module dependency.

NotificationPreferencesModule already wraps its back-reference with forwardRef(() => NotificationModule). "In order to resolve circular dependencies between modules, use the same forwardRef() utility function on both sides of the modules association." Without it here, startup behaviour depends on module load order, which is usually not stable, so having forwardRef everywhere is the safer approach.

🛠 Proposed fix
-import { Module } from '@nestjs/common';
+import { forwardRef, Module } from '@nestjs/common';
 ...
 `@Module`({
-  imports: [NotificationPreferencesModule],
+  imports: [forwardRef(() => NotificationPreferencesModule)],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/notification.module.ts` at line 13, The
NotificationModule's imports currently list NotificationPreferencesModule
without forwardRef; to fix the circular dependency wrap that entry with
forwardRef(() => NotificationPreferencesModule) in the imports array of
NotificationModule (import forwardRef from `@nestjs/common` if not already) so it
matches the back-reference in NotificationPreferencesModule and resolves startup
order instability caused by the circular module dependency.
src/components/notifications/preferences/notification-preference.dto.ts (1)

56-56: Extraneous | undefined on description.

@Field({ nullable: true }) maps to null in the GraphQL schema; the | undefined broadens the TypeScript type unnecessarily. string | null is sufficient and consistent with the nullable contract.

✨ Suggested fix
-  readonly description: string | null | undefined;
+  readonly description: string | null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/preferences/notification-preference.dto.ts` at
line 56, The NotificationPreference DTO's description property currently types
as "string | null | undefined"; update the property declaration in
notification-preference.dto (the description field on the DTO class) to
"readonly description: string | null;" to match the `@Field`({ nullable: true })
GraphQL contract and remove the extraneous "| undefined". Also scan other DTO
fields in the same class for similar "| undefined" usages and align them to
either plain types or "T | null" when decorated with nullable.
src/components/notifications/notification.strategy.ts (1)

46-50: getLabel() uses this.constructor.name — beware of minification.

If the codebase is ever minified (class names mangled), this.constructor.name will produce garbled labels. This is fine for a server-side Node.js app that doesn't minify, but worth a quick note.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/notification.strategy.ts` around lines 46 - 50,
getLabel() relies on this.constructor.name which breaks under minification;
replace this runtime-reflection with an explicit identifier: add a static or
instance property (e.g., static label or protected label) on each concrete
NotificationStrategy subclass and change getLabel() to read that property
(falling back to a safe default) instead of using this.constructor.name and
Case.capital; update NotificationStrategy and all subclasses to declare the
label value.
src/components/notifications/preferences/notification-preferences.service.ts (1)

96-109: Double-grouping in getOverridesMap is slightly wasteful.

The rows are already fetched filtered by notificationType (passed to repo.getOverridesForUsers), yet groupOverridesByType re-groups them by type before extracting just the one type. This works correctly but does redundant work. Not a problem at current scale — just noting for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/preferences/notification-preferences.service.ts`
around lines 96 - 109, getOverridesMap does redundant work by calling
groupOverridesByType after repo.getOverridesForUsers already returned rows
filtered by notificationType; instead, avoid the extra grouping by type and
directly group rows by user id and compute the Partial<ChannelSettings> for the
given notificationType. Update getOverridesMap to use groupToMapBy(rows, row =>
row.user.id) (or existing grouping helper) and for each user’s rows
merge/compute overrides for the provided notificationType (instead of calling
groupOverridesByType), keeping the existing return shape and using the same
mapValues/...asMap pattern.
src/components/notifications/notification.service.ts (2)

78-81: Duplicated type-name derivation acknowledged but still a maintenance risk.

The comment on Line 79 acknowledges this is duplicated from the NotificationStrategy decorator. If the naming convention ever changes (e.g., class suffix changes), both sites must be updated in lockstep.

Consider extracting a shared helper or looking up the decorator metadata directly via the class (since typeClassToName exists on the impl). Since getTypeName is on the abstract NotificationService and typeClassToName is on the impl, a simple lookup won't work here — but worth considering if this method could be moved to the impl or use the map.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/notification.service.ts` around lines 78 - 81,
The getTypeName method in NotificationService duplicates the class-name-to-type
conversion used by the NotificationStrategy decorator, creating a maintenance
hazard; fix it by centralizing the logic: either extract the conversion into a
shared helper (e.g., a new util function like deriveNotificationTypeName) and
call that from both NotificationService.getTypeName and the NotificationStrategy
decorator, or move getTypeName out of the abstract NotificationService into the
concrete implementation and reuse the existing typeClassToName map/metadata on
the impl; update all call sites (getTypeName, NotificationStrategy) to use the
single shared function or the impl's map so the suffix-to-type rule is
maintained in one place.

196-223: Duplicated instanceof check across two iterations of the same discovered array.

The validation at Lines 201–205 and Lines 212–216 are identical checks on the same instances. Consider iterating once and building all three maps in a single pass.

Proposed refactor
   async onModuleInit() {
     const discovered = this.discovery.discover(NotificationStrategy).classes();
-    this.strategiesByClassType = mapEntries(
-      discovered,
-      ({ meta: { cls }, instance }) => {
-        if (!(instance instanceof INotificationStrategy)) {
-          throw new ServerException(
-            `Strategy for ${cls.name} does not implement INotificationStrategy`,
-          );
-        }
-        return [cls, instance];
-      },
-    ).asMap;
-    this.strategiesByNameType = mapEntries(
-      discovered,
-      ({ meta: { cls, typeName }, instance }) => {
-        if (!(instance instanceof INotificationStrategy)) {
-          throw new ServerException(
-            `Strategy for ${cls.name} does not implement INotificationStrategy`,
-          );
-        }
-        return [typeName, instance];
-      },
-    ).asMap;
-    this.typeClassToName = mapEntries(discovered, ({ meta }) => [
-      meta.cls,
-      meta.typeName,
-    ]).asMap;
+    const byClass = new Map<ResourceShape<Notification>, INotificationStrategy<Notification>>();
+    const byName = new Map<NotificationType, INotificationStrategy<Notification>>();
+    const classToName = new Map<ResourceShape<Notification>, NotificationType>();
+    for (const { meta: { cls, typeName }, instance } of discovered) {
+      if (!(instance instanceof INotificationStrategy)) {
+        throw new ServerException(
+          `Strategy for ${cls.name} does not implement INotificationStrategy`,
+        );
+      }
+      byClass.set(cls, instance);
+      byName.set(typeName, instance);
+      classToName.set(cls, typeName);
+    }
+    this.strategiesByClassType = byClass;
+    this.strategiesByNameType = byName;
+    this.typeClassToName = classToName;
     this.ready.set();
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/notification.service.ts` around lines 196 - 223,
The onModuleInit currently loops over discovered twice and duplicates the
instanceof INotificationStrategy check; instead, iterate once over discovered
(the result of this.discovery.discover(NotificationStrategy).classes()),
validate each instance is instanceof INotificationStrategy once (throwing
ServerException with the same message if not) and inside that single loop
populate strategiesByClassType, strategiesByNameType and typeClassToName (using
the same mapping logic currently in the two mapEntries calls) so you produce the
three maps in one pass and then assign them to this.strategiesByClassType,
this.strategiesByNameType and this.typeClassToName.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/notifications/dto/notification-channel.enum.ts`:
- Around line 15-22: The resolve function's second parameter is too narrow;
update the signature of resolve to accept DB-null overrides by changing the type
of the second parameter to boolean | null | undefined (e.g., resolve:
(availability: EnumType<typeof ca>, override: boolean | null | undefined) => {
... }) so callers can pass NotificationChannelPreference.override (typed boolean
| null) directly while the implementation can continue using ?? to handle
defaults.

In
`@src/components/notifications/preferences/notification-preferences.gel.repository.ts`:
- Around line 33-40: The mapping that builds PreferenceOverrideRow uses
castToEnum for both notificationType and channel but the channel fallback is
currently undefined (via "undefined as unknown as NotificationChannel"), which
can produce undefined at runtime and violate the non-optional
PreferenceOverrideRow.channel; change the channel fallback to a defined sentinel
like '' as NotificationChannel (or another valid NotificationChannel sentinel)
so castToEnum(...) for channel mirrors notificationType's fallback and always
returns a non-undefined NotificationChannel; update the castToEnum call for
channel accordingly where the PreferenceOverrideRow is constructed.
- Around line 19-44: The Gel implementation of getOverridesForUsers is missing
the optional notificationType parameter and thus ignores it; update the
getOverridesForUsers method signature to accept notificationType?:
NotificationType and forward that value into the db.run call (pass it into
getOverridesForUsersQuery params), then modify getOverridesForUsersQuery to
accept the notificationType param and include a server-side filter on
pref.notificationType (using the same castToEnum<typeof pref.notificationType,
NotificationType> helper) so that when notificationType is provided only
matching Preference rows are returned; reference symbols: getOverridesForUsers,
getOverridesForUsersQuery, pref.notificationType, NotificationType, castToEnum.

In
`@src/components/notifications/preferences/notification-preferences.resolver.ts`:
- Around line 39-52: The `@Mutation` decorator for updateNotificationPreferences
declares a single NotificationPreference but the resolver returns an array;
change the decorator to return an array type (use the array form of
NotificationPreference like the sibling Query) so the GraphQL SDL matches the
resolver signature for updateNotificationPreferences and NotificationPreference.

---

Nitpick comments:
In `@src/components/notifications/notification.module.ts`:
- Line 13: The NotificationModule's imports currently list
NotificationPreferencesModule without forwardRef; to fix the circular dependency
wrap that entry with forwardRef(() => NotificationPreferencesModule) in the
imports array of NotificationModule (import forwardRef from `@nestjs/common` if
not already) so it matches the back-reference in NotificationPreferencesModule
and resolves startup order instability caused by the circular module dependency.

In `@src/components/notifications/notification.service.ts`:
- Around line 78-81: The getTypeName method in NotificationService duplicates
the class-name-to-type conversion used by the NotificationStrategy decorator,
creating a maintenance hazard; fix it by centralizing the logic: either extract
the conversion into a shared helper (e.g., a new util function like
deriveNotificationTypeName) and call that from both
NotificationService.getTypeName and the NotificationStrategy decorator, or move
getTypeName out of the abstract NotificationService into the concrete
implementation and reuse the existing typeClassToName map/metadata on the impl;
update all call sites (getTypeName, NotificationStrategy) to use the single
shared function or the impl's map so the suffix-to-type rule is maintained in
one place.
- Around line 196-223: The onModuleInit currently loops over discovered twice
and duplicates the instanceof INotificationStrategy check; instead, iterate once
over discovered (the result of
this.discovery.discover(NotificationStrategy).classes()), validate each instance
is instanceof INotificationStrategy once (throwing ServerException with the same
message if not) and inside that single loop populate strategiesByClassType,
strategiesByNameType and typeClassToName (using the same mapping logic currently
in the two mapEntries calls) so you produce the three maps in one pass and then
assign them to this.strategiesByClassType, this.strategiesByNameType and
this.typeClassToName.

In `@src/components/notifications/notification.strategy.ts`:
- Around line 46-50: getLabel() relies on this.constructor.name which breaks
under minification; replace this runtime-reflection with an explicit identifier:
add a static or instance property (e.g., static label or protected label) on
each concrete NotificationStrategy subclass and change getLabel() to read that
property (falling back to a safe default) instead of using this.constructor.name
and Case.capital; update NotificationStrategy and all subclasses to declare the
label value.

In `@src/components/notifications/preferences/notification-preference.dto.ts`:
- Line 56: The NotificationPreference DTO's description property currently types
as "string | null | undefined"; update the property declaration in
notification-preference.dto (the description field on the DTO class) to
"readonly description: string | null;" to match the `@Field`({ nullable: true })
GraphQL contract and remove the extraneous "| undefined". Also scan other DTO
fields in the same class for similar "| undefined" usages and align them to
either plain types or "T | null" when decorated with nullable.

In
`@src/components/notifications/preferences/notification-preferences.service.ts`:
- Around line 96-109: getOverridesMap does redundant work by calling
groupOverridesByType after repo.getOverridesForUsers already returned rows
filtered by notificationType; instead, avoid the extra grouping by type and
directly group rows by user id and compute the Partial<ChannelSettings> for the
given notificationType. Update getOverridesMap to use groupToMapBy(rows, row =>
row.user.id) (or existing grouping helper) and for each user’s rows
merge/compute overrides for the provided notificationType (instead of calling
groupOverridesByType), keeping the existing return shape and using the same
mapValues/...asMap pattern.

@CarsonF CarsonF force-pushed the notifications/config branch from 76285c5 to 338ced1 Compare February 20, 2026 20:53
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/components/notifications/notification.service.ts (1)

198-223: Duplicated instanceof validation across two mapEntries passes — consider a single pass.

The instanceof INotificationStrategy check is identical in both strategiesByClassType and strategiesByNameType builds. A single validated pass would be slightly cleaner and avoid iterating discovered twice.

♻️ Single-pass initialization
-    this.strategiesByClassType = mapEntries(
-      discovered,
-      ({ meta: { cls }, instance }) => {
-        if (!(instance instanceof INotificationStrategy)) {
-          throw new ServerException(
-            `Strategy for ${cls.name} does not implement INotificationStrategy`,
-          );
-        }
-        return [cls, instance];
-      },
-    ).asMap;
-    this.strategiesByNameType = mapEntries(
-      discovered,
-      ({ meta: { cls, typeName }, instance }) => {
-        if (!(instance instanceof INotificationStrategy)) {
-          throw new ServerException(
-            `Strategy for ${cls.name} does not implement INotificationStrategy`,
-          );
-        }
-        return [typeName, instance];
-      },
-    ).asMap;
-    this.typeClassToName = mapEntries(discovered, ({ meta }) => [
-      meta.cls,
-      meta.typeName,
-    ]).asMap;
+    const validated = discovered.map(({ meta: { cls, typeName }, instance }) => {
+      if (!(instance instanceof INotificationStrategy)) {
+        throw new ServerException(
+          `Strategy for ${cls.name} does not implement INotificationStrategy`,
+        );
+      }
+      return { cls, typeName, instance };
+    });
+    this.strategiesByClassType = new Map(validated.map(({ cls, instance }) => [cls, instance]));
+    this.strategiesByNameType = new Map(validated.map(({ typeName, instance }) => [typeName, instance]));
+    this.typeClassToName = new Map(validated.map(({ cls, typeName }) => [cls, typeName]));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/notifications/notification.service.ts` around lines 198 - 223,
The duplicate instanceof checks should be consolidated into a single pass over
discovered: iterate discovered once (using mapEntries or a simple loop),
validate instance instanceof INotificationStrategy (throwing the same
ServerException when invalid), and during that pass produce the three mappings
needed for strategiesByClassType, strategiesByNameType and typeClassToName; then
assign their .asMap (or converted maps) to strategiesByClassType,
strategiesByNameType and typeClassToName respectively. Ensure you reference the
same meta.cls, meta.typeName and instance when building the three outputs so you
no longer iterate discovered twice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/notifications/notification.service.ts`:
- Around line 119-155: The notification is persisted via super.create(...)
before preferences are fetched, so a failure in preferences fetch can leave an
undelivered row; move the preferences lookup and channels computation ahead of
persisting: call this.preferencesService.getOverridesMap(...) and compute
channelsForUsers (using the original recipients parameter or out.recipients
fallback logic—i.e., the same userRecipients calculation but based on the input
recipients) and determine strategy.broadcastTo() targets first, then call
super.create(type, recipients, input) to persist and finally call
deliverToAppChannel(notification, ...). Update code paths that reference
out.recipients so they still work (if you need out.recipients after create, keep
creating but compute the preference-based channel lists beforehand), and ensure
any errors from getOverridesMap still bubble or are handled explicitly (so you
don’t persist without delivery).

In
`@src/components/notifications/preferences/notification-preferences.repository.ts`:
- Around line 33-45: The current MATCH uses
node('pref','NotificationPreference',{ notificationType }) which passes
undefined as null and causes MATCH to restrict to notificationType IS null;
change this to always match NotificationPreference without properties and, if
notificationType is provided, add a conditional .where({
'pref.notificationType': notificationType }) (or only call .where for that
predicate when notificationType !== undefined) so getPreferences() and other
callers return all preferences when the filter is omitted; keep the existing
apoc.map.merge and return logic intact.

---

Duplicate comments:
In `@src/components/notifications/dto/notification-channel.enum.ts`:
- Around line 15-22: The resolve function's signature narrows override to
boolean | undefined while callers may pass DB-derived values that can be null;
widen the override parameter type to boolean | null | undefined in the resolve
declaration (the function with parameters "availability: EnumType<typeof ca>"
and "override") so the TypeScript types match runtime behavior and the null case
handled by the existing ?? expression remains valid.

---

Nitpick comments:
In `@src/components/notifications/notification.service.ts`:
- Around line 198-223: The duplicate instanceof checks should be consolidated
into a single pass over discovered: iterate discovered once (using mapEntries or
a simple loop), validate instance instanceof INotificationStrategy (throwing the
same ServerException when invalid), and during that pass produce the three
mappings needed for strategiesByClassType, strategiesByNameType and
typeClassToName; then assign their .asMap (or converted maps) to
strategiesByClassType, strategiesByNameType and typeClassToName respectively.
Ensure you reference the same meta.cls, meta.typeName and instance when building
the three outputs so you no longer iterate discovered twice.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/notifications/notification.repository.ts`:
- Around line 87-101: The generated Cypher can end up with two consecutive WHERE
clauses when recipients is non-null; in the .apply(...) block that returns
q.raw(...) for appAvailability handling (referencing appAvailability,
isDefaultOn, this.getType(type)), insert a "WITH recipient" (i.e., pass the
recipient forward) before emitting the raw WHERE/EXISTS clause so the WHERE
binds to a new clause rather than repeating after the previous MATCH/WHERE;
update the .apply call to emit a WITH recipient then the existing raw EXISTS
predicate to ensure valid Cypher in both the MATCH and CALL {} paths.

…references

The create query now checks:
- Strategy's App ChannelAvailability (AlwaysOff skips all recipients)
- DefaultOn: excludes recipients who disabled App via NotificationPreference
- DefaultOff: only includes recipients who enabled App via NotificationPreference
- AlwaysOn: no filtering (existing behavior)

Co-authored-by: CarsonF <932566+CarsonF@users.noreply.github.com>
@CarsonF CarsonF force-pushed the notifications/config branch from c819fb1 to 903840f Compare February 21, 2026 21:49
Apparently you can't pass undefined, because it creates a parameter
binding that this is not fulfilled.
Copy link

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.

Pull request overview

Adds user-configurable notification preferences per delivery channel (currently only App), and integrates preference-aware delivery and recipient filtering into the notification pipeline.

Changes:

  • Introduces Notification Preferences GraphQL API (query + mutation) with DTOs/service + split DB repositories (Neo4j + Gel).
  • Adds notification channel & availability enums and a dynamic NotificationType enum driven by registered strategies.
  • Updates notification creation/delivery to honor per-user channel preferences (and filters stored recipients accordingly for the App channel).

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/components/notifications/preferences/notification-preferences.service.ts Builds effective preferences and persists/removes per-channel overrides; provides overrides lookup map for delivery.
src/components/notifications/preferences/notification-preferences.resolver.ts Exposes GraphQL query/mutation for reading/updating current user preferences plus a convenience channels field.
src/components/notifications/preferences/notification-preferences.repository.ts Neo4j repository for reading/upserting/deleting preference override nodes.
src/components/notifications/preferences/notification-preferences.gel.repository.ts Gel repository implementation of the same overrides read/write operations.
src/components/notifications/preferences/notification-preferences.module.ts Wires preferences service/resolver and split DB repository; exports service for notification delivery usage.
src/components/notifications/preferences/notification-preference.dto.ts GraphQL object/input types for preferences and per-channel overrides.
src/components/notifications/notification.strategy.ts Enhances strategy metadata to register notification types and adds channel availability/label/description hooks.
src/components/notifications/notification.service.ts Adds strategy lookup maps by class/name and delivers notifications according to user channel preferences.
src/components/notifications/notification.repository.ts Filters recipient creation for App channel based on stored preference overrides (Neo4j).
src/components/notifications/notification.gel.repository.ts Updates strategy iteration to use the renamed strategy map (Gel).
src/components/notifications/notification.module.ts Imports preferences module so delivery can consult overrides.
src/components/notifications/index.ts Re-exports NotificationType and NotificationMap for module augmentation.
src/components/notifications/dto/notification-type.enum.ts Introduces dynamic NotificationType enum derived from registered concrete notification types.
src/components/notifications/dto/notification-channel.enum.ts Adds NotificationChannel and ChannelAvailability enums (+ helpers).
src/components/notifications/dto/index.ts Exports new enums/types from notifications DTO barrel.
src/components/notification-system/system-notification.dto.ts Registers System notification type via NotificationMap module augmentation.
src/components/comments/mention-notification/comment-via-mention-notification.dto.ts Registers CommentViaMention notification type via NotificationMap module augmentation.
dbschema/notifications.gel Adds Notification::Preference schema for storing per-user overrides in Gel.
dbschema/migrations/00030-m1ded7e.edgeql Migration creating the new Gel preference type + exclusive constraint.
Comments suppressed due to low confidence (2)

src/components/notifications/preferences/notification-preferences.service.ts:109

  • getOverridesMap() groups rows by user, but then calls groupOverridesByType() for each user group even though the repo query is already filtered to a single notificationType. This adds unnecessary extra grouping/allocations per user. Consider building the per-user channel settings directly from that user’s rows (channel → enabled) without re-grouping by type.
  async getOverridesMap(
    notificationType: NotificationType,
    userIds: ReadonlyArray<ID<'User'>>,
  ): Promise<ReadonlyMap<ID<'User'>, Partial<ChannelSettings>>> {
    if (userIds.length === 0) return new Map();
    const rows = await this.repo.getOverridesForUsers(
      userIds,
      notificationType,
    );
    return mapValues(
      groupToMapBy(rows, (row) => row.user.id),
      (_, rows) => this.groupOverridesByType(rows).get(notificationType) ?? {},
    ).asMap;
  }

src/components/notifications/preferences/notification-preferences.resolver.ts:52

  • This PR introduces new GraphQL query/mutation behavior (reading/updating notification preferences) and changes notification delivery filtering based on those preferences, but there are no e2e tests covering the new operations or the delivery behavior (e.g., opting out prevents app delivery / listing). Add e2e coverage to prevent regressions as new channels (Email) are added.
  @Query(() => [NotificationPreference], {
    description: 'The effective notification preferences for the current user',
  })
  async notificationPreferences(): Promise<readonly NotificationPreference[]> {
    return await this.preferences.getPreferences();
  }

  @ResolveField(() => [NotificationChannel], {
    description: 'Convenience list of channels that are currently enabled',
  })
  channels(
    @Parent() preference: NotificationPreference,
  ): readonly NotificationChannel[] {
    return preference.channelPreferences.flatMap((p) =>
      p.enabled ? p.channel : [],
    );
  }

  @Mutation(() => [NotificationPreference], {
    description: stripIndent`
      Update the current user's notification preferences for specific types

      Note that items should only have a notificationType once, and duplicates are ignored.
    `,
  })
  async updateNotificationPreferences(
    @Args('input', { type: () => [UpdateNotificationPreference] })
    input: readonly UpdateNotificationPreference[],
  ): Promise<readonly NotificationPreference[]> {
    await this.preferences.updatePreferences(input);
    return await this.preferences.getPreferences();
  }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +37
private readonly getOverridesForUsersQuery = e.params(
{ userIds: e.array(e.uuid), notificationType: e.optional(e.str) },
({ userIds, notificationType }) => {
const users = e.cast(e.User, e.array_unpack(userIds));
return e.select(e.Notification.Preference, (pref) => ({
filter: e.op(
e.op(pref.user, 'in', users),
'and',
e.op(pref.notificationType, '=', notificationType),
),
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

In Gel mode, this query always filters by pref.notificationType = notificationType. When notificationType is not provided, the optional parameter will be empty and the equality check prevents any rows from matching, so getOverridesForUsers(userIds) will effectively always return an empty list. Make the notificationType predicate conditional (only applied when a notificationType is passed) so fetching all overrides works correctly.

Copilot uses AI. Check for mistakes.
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.

3 participants