#26 — In-App Notification Center #26

Closed
opened 2026-08-18 13:12:56 +02:00 by lena · 4 comments
lena commented 2026-08-18 13:12:56 +02:00 (Migrated from git.butzei.de)

Story: In-App Notification Center

As a list member,
I want a notification bell that shows me relevant updates,
so that I know when something needs my attention without constantly checking every list.

Acceptance criteria:

  • A bell icon appears in the application header for every logged-in user
  • A badge on the bell shows the count of unread notifications (hidden when count is zero)
  • Clicking the bell opens a panel or dropdown listing recent notifications, newest first
  • The following events generate a notification for the affected user:
    • You were added to a list ("Alex added you to 'Groceries'")
    • A todo was assigned to you (#16)
    • A todo assigned to you is due today
    • A todo assigned to you is now overdue
  • Each notification shows the event description, list name, and relative time
  • Clicking a notification navigates to the relevant list (and todo if applicable) and marks it as read
  • A "Mark all as read" action clears the badge
  • Notifications persist across sessions (stored in the backend); they survive page reload
  • Notifications older than 30 days are automatically purged

Out of scope for this story:

  • Email notifications (that is #28)
  • Browser push notifications (that is #29)
  • User-configurable notification preferences (all above events are on by default)
  • Notifications for activity-feed events not directly addressed to the user

Blockers: None (richer content possible after #16 assign todo is delivered)

Priority: Should — closes the feedback loop for assigned work and membership changes.

# Story: In-App Notification Center **As a** list member, **I want** a notification bell that shows me relevant updates, **so that** I know when something needs my attention without constantly checking every list. **Acceptance criteria:** - [ ] A bell icon appears in the application header for every logged-in user - [ ] A badge on the bell shows the count of unread notifications (hidden when count is zero) - [ ] Clicking the bell opens a panel or dropdown listing recent notifications, newest first - [ ] The following events generate a notification for the affected user: - You were added to a list ("Alex added you to 'Groceries'") - A todo was assigned to you (`#16`) - A todo assigned to you is due today - A todo assigned to you is now overdue - [ ] Each notification shows the event description, list name, and relative time - [ ] Clicking a notification navigates to the relevant list (and todo if applicable) and marks it as read - [ ] A "Mark all as read" action clears the badge - [ ] Notifications persist across sessions (stored in the backend); they survive page reload - [ ] Notifications older than 30 days are automatically purged **Out of scope for this story:** - Email notifications (that is `#28`) - Browser push notifications (that is `#29`) - User-configurable notification preferences (all above events are on by default) - Notifications for activity-feed events not directly addressed to the user **Blockers:** None (richer content possible after `#16` assign todo is delivered) **Priority:** Should — closes the feedback loop for assigned work and membership changes.
lena commented 2026-08-18 13:12:56 +02:00 (Migrated from git.butzei.de)

security (26_in_app_notifications_security.md)

Security Pre-Review: Story #26 — In-App Notification Center

Reviewer: Security Agent
Date: 2026-07-09
Design document: 26_in_app_notifications_design.md
Status: Approved with required changes


Summary

The overall design is architecturally sound. Authorization scoping for the query and bulk-mark-all paths is correct. The WebSocket gating pattern mirrors the existing publishers faithfully. Six issues are raised below; two are required changes that must be resolved before or during implementation, and four are lower-severity items with clear mitigations.


Findings

1. IDOR / Authorization — MarkNotificationReadCommand

Severity: High

The handler correctly performs an ownership check (entity.RecipientId != currentUserId → throw UnauthorizedAccessException). However, as the design itself flags, NotificationId is a sequential auto-increment integer. A motivated attacker who holds one valid NotificationId (from their own session) can enumerate adjacent IDs and invoke MarkNotificationReadCommand against them, probing for the existence of other users' notifications.

The current exception mapping in ExceptionHandler.cs distinguishes cleanly:

Exception HTTP status
EntityNotFoundException 404
UnauthorizedAccessException 403

This means the handler's current design returns 403 when a user probes another user's notification that exists, which confirms the notification ID is valid for some user. An attacker can therefore distinguish "this ID exists but belongs to someone else" (403) from "this ID does not exist" (404). This is an oracle for notification existence.

Recommendation (required): When RecipientId != currentUserId, throw EntityNotFoundException rather than UnauthorizedAccessException. The caller receives 404 in both the "does not exist" and "belongs to another user" cases, eliminating the enumeration oracle. The internal message can still be "not your notification" for logging purposes. Document this as a deliberate security decision in the handler comment.


2. Sequential NotificationId Exposure (Design Q5)

Severity: Medium

Related to Finding 1. Sequential integer PKs allow an authenticated user to infer the total notification volume of the system (by comparing the highest ID they have received with neighbouring IDs). While this does not directly expose content, it is an information leak about system activity.

Recommendation: For this MVP, the 404-masking fix in Finding 1 is the mandatory control. Switching to a non-sequential ID type (e.g., ULID or UUID v7) would eliminate the enumeration vector entirely but is a scope change. It is acceptable to defer that to a follow-up story, provided the 404-masking is in place at launch.


3. Information Leakage — NotificationDto exposes RecipientId over WebSocket

Severity: Medium

NotificationDto is defined as:

public record NotificationDto(
    NotificationId Id,
    NotificationKind Kind,
    string Body,
    TodoListId TodoListId,
    TodoListTitle TodoListTitle,
    TodoNr? TodoNr,
    bool IsRead,
    DateTimeOffset CreatedAt);

The design document's §6.3 filter description implies the DTO carries RecipientId for WS routing (change.Data!.RecipientId == userId.Value). However, the NotificationDto record as defined does not include RecipientId. The design text is inconsistent: §6.3 references change.Data!.RecipientId as the filter field, but the DTO definition in §3.1 has no such field.

If RecipientId is added to NotificationDto to enable the WS filter, it will be serialised into the JSON payload sent to the client. The client already knows their own user ID so this is not a cross-user leak, but it does expose an internal database integer (the user's primary key) unnecessarily.

There are two clean alternatives:

  1. Preferred: Store RecipientId in a parallel wrapper type that is used only for in-process routing and never serialised. The WS publisher holds notificationChanges as IObservable<Change<NotificationId, NotificationDto>> — this type carries the DTO. A lightweight approach is to use a separate IObservable<(UserId RecipientId, Change<NotificationId, NotificationDto>)> internally so the recipient is routed without embedding it in the DTO.
  2. Acceptable for MVP: Add RecipientId to NotificationDto but annotate it with [JsonIgnore] (System.Text.Json) so it is used for in-process routing but not sent over the wire or returned by the REST queries. Ensure the JSON serialiser options on the WS send path respect [JsonIgnore] (the existing JsonOptions in UserScopedTodoListChangePublisher do not configure DefaultIgnoreCondition, so [JsonIgnore] will be respected).

Recommendation (required): Choose one of the two approaches above and document it in the implementation. The WS payload sent to the browser must not contain RecipientId.


4. Notification Body Injection — TodoListTitle Lacks a Length Cap

Severity: Medium

The Body field is constructed server-side from persisted data:

var body = $"You were added to '{listTitle}'";   // AcceptListInvitation
var body = $"'{dto.Title.Value}' was assigned to you";  // AssignTodo
// Background job: "<title> is due today" / "<title> is overdue"

TodoTitle (in Common/Types/TodoTitle.cs) is validated with a hard cap of 1,024 characters — safe.

TodoListTitle (in Common/Types/TodoListTitle.cs) validates only that the value is non-null. There is no length cap. A user who creates a list with a very long title (e.g., 10,000 characters) causes the persisted Body field to be arbitrarily large. This has two secondary consequences:

  1. The Body column has no MaxLength configured in the EF Core entity (builder.Property(x => x.Kind).HasConversion<string>() is the only property annotation shown; Body gets the default text column type in PostgreSQL, which accepts up to 1 GB).
  2. The WS payload and REST response can carry an unexpectedly large string, which could degrade client performance and consume memory.

There is no HTML or SQL injection risk here because the value is stored as plain text and never rendered as raw HTML server-side, and EF Core parameterises all values. The risk is a DB storage and wire-size concern.

Recommendation: Add a length cap to TodoListTitle.Validate() — 255 characters is conventional for a list title and consistent with UX constraints. Additionally, add builder.Property(x => x.Body).HasMaxLength(2048) (or a derived maximum that accounts for the longest possible template with the longest valid title) to the NotificationEntity configuration so the DB enforces the cap as a second line of defence.


5. WebSocket Authentication — New Endpoint Correctly Follows Established Pattern

Severity: Info

UserScopedTodoListChangePublisher.cs (the reference implementation) performs the following sequence before accepting the WebSocket:

  1. Check context.WebSockets.IsWebSocketRequest → 400 if false.
  2. Call getUserIdHandler.Handle(new GetCurrentUserIdQuery(), ...) → 401 if null.
  3. Only then call context.WebSockets.AcceptWebSocketAsync().

This order is critical: accepting the WebSocket before verifying the user would allow an unauthenticated connection to be upgraded and then terminated abortly, wasting server resources and potentially leaking connection state.

The design for UserScopedNotificationPublisher (§6.3) shows the same correct order (userId is null → StatusCode = 401; return). As long as the implementation follows this pattern exactly — which the existing publisher code makes clear — the new endpoint is safe.

Recommendation: No change required. Add a unit test that verifies a non-WebSocket request to /api/changes/NotificationDto gets 400, and an unauthenticated WebSocket upgrade attempt gets 401, consistent with the existing WS publisher tests (if any exist).


6. Rate Limiting — No Existing Middleware; New Endpoints Are Unprotected

Severity: Medium

A search of the codebase finds no rate limiting middleware, no AddRateLimiter / UseRateLimiter call, no AspNetCoreRateLimit package reference, and no per-endpoint throttle attribute anywhere in the project. This is a pre-existing gap, not introduced by this story.

The new queries GetNotificationsForCurrentUserQuery and GetUnreadNotificationCountForCurrentUserQuery are routed via MapRequests, which maps all public IRequest types to POST /api/{TypeName}. Both queries hit the DB and are callable in a tight loop by an authenticated user.

GetNotificationsForCurrentUserQuery already caps Limit at 200 rows (design §5.1), which limits per-request DB cost. The count query is a single COUNT(*) over an indexed column.

Recommendation: The absence of rate limiting is a pre-existing gap that is out of scope for this story to fix. However, the team should track it as a separate security backlog item. For this story specifically, ensure the Limit parameter validation (max 200) is enforced by a guard in the handler body — e.g., if (request.Limit > 200) throw new ArgumentException(...) — rather than relying on the frontend to send a sane value.


7. Multi-Replica Duplicate WS Publish — Affected-Row Count Mitigation Is Correct

Severity: Info / Confirmed

Design Q7 asks whether checking the affected-row count from ExecuteSqlInterpolatedAsync is sufficient to suppress duplicate WS publishes when multiple replicas run DueNotificationJob concurrently.

Confirmed: yes, this is the correct mitigation. ExecuteSqlInterpolatedAsync returns the number of rows affected. For an INSERT ... ON CONFLICT DO NOTHING statement, the return value is:

  • 1 if the row was inserted (this replica wins; it should publish).
  • 0 if the conflict fired and nothing was inserted (another replica already inserted; skip the publish).

Because the unique constraint UX_NotificationEntity_IdempotencyKey is enforced by PostgreSQL atomically, exactly one replica will see a return value of 1 for any given (RecipientId, TodoListId, TodoNr, Kind, DATE(CreatedAt)) tuple. Only that replica should call notificationSubject.OnNext(...).

Implementation note: The design describes fetching inserted IDs "with a short time window" after the bulk insert. This approach is fragile in a multi-replica scenario because both replicas may query the table and find the newly inserted rows, causing both to publish. The recommended approach is to batch the inserts and check the return value of each individual INSERT ... ON CONFLICT DO NOTHING call, publishing only when the return is 1. If a true bulk insert (multiple rows in a single SQL statement) is used, the total return value must equal the expected insert count; any shortfall means some conflicts occurred and those specific rows must not be republished. The implementation must be careful here — a naive "fetch rows inserted in the last N seconds" approach will produce duplicate WS events.


8. Self-Assignment Suppression (PO Decision Q2) — Security Implications

Severity: Info

The PO has decided: if request.AssigneeId == currentUserId, no notification is created. This is implemented by comparing the current user's ID against the assignee ID before calling CreateNotificationCommand.

From a security standpoint, this check is straightforward and benign. There is no TOCTOU risk: the currentUserId is resolved from the session inside the same request scope. The only edge case is an authorisation-level concern: the AssignTodoCommandHandler already enforces that the assignee is a member of the list (isMember check), so a user cannot self-assign to a list they don't belong to, and they certainly cannot assign to someone else without that person being a member. The suppression logic introduces no new attack surface.

Recommendation: No security concern. Implement as designed. Add a unit test asserting no notification row is created when assigneeId == currentUserId.


9. ON DELETE CASCADE — Both FK Constraints

Severity: Low

Both RecipientId → UserEntity(Id) and TodoListId → TodoListEntity(Id) use ON DELETE CASCADE. This means:

  • User deleted: All their notification rows vanish. Correct and expected; a deleted user's data should not linger.
  • List deleted: All notification rows referencing that list vanish. Correct; a deleted list's "you were added" and assignment notifications are meaningless artefacts.

One subtle concern: if a user is deleted while another user's notification references the deleted user as an actor (e.g., "Alex added you to 'X'"), that notification also vanishes because RecipientId is the joining user's ID — not the actor's ID. There is no FK on the inviter or assigner. This is correct by the schema design.

However, if in a future story the inviter's name is added to the Body or a new ActorId FK column is introduced pointing to UserEntity, that FK should use ON DELETE SET NULL or ON DELETE RESTRICT, not CASCADE, to avoid silently deleting the recipient's notification when an unrelated user (the actor) is deleted.

Recommendation: No change required for this story. Add a comment in the migration and entity configuration noting that any future ActorId FK must not use CASCADE.


Authorization Model Summary Assessment

Operation Authentication Ownership / Scope Assessment
GetNotificationsForCurrentUserQuery Required WHERE scoped to currentUserId Correct
GetUnreadNotificationCountForCurrentUserQuery Required COUNT scoped to currentUserId Correct
MarkNotificationReadCommand Required Ownership check in handler body Correct logic; fix exception type (Finding 1)
MarkAllNotificationsReadCommand Required ExecuteUpdate scoped to currentUserId Correct
CreateNotificationCommand (internal) N/A (not HTTP-exposed) Caller-supplied RecipientId from trusted handlers/job Acceptable
CreateDueNotificationsCommand (internal) N/A (job context) RecipientId taken from DB rows (AssigneeId) Correct
WS /api/changes/NotificationDto 401 before upgrade Filter change.Data.RecipientId == userId Correct (see Finding 3 re: DTO field)

Required Changes Before or During Implementation

  1. Finding 1 (High): In MarkNotificationReadCommandHandler, change throw new UnauthorizedAccessException(...) to throw EntityNotFound.Create<NotificationEntity>(...) when RecipientId != currentUserId. This eliminates the 403-vs-404 enumeration oracle.

  2. Finding 3 (Medium): Resolve the RecipientId field ambiguity. Either use a wrapper observable type that carries recipient routing data outside the serialised DTO, or add [JsonIgnore] to RecipientId if it must live on NotificationDto. The JSON payload sent to the browser must not include RecipientId.

  1. Finding 4 (Medium): Add a length cap to TodoListTitle.Validate() (suggested: 255 characters). Add HasMaxLength to the Body property in NotificationEntity EF Core configuration.

  2. Finding 6 (Medium): Enforce the Limit <= 200 constraint in the handler body with an ArgumentException, not just as a documented convention.

  3. Finding 7 (Info/Confirmed): Ensure the multi-replica WS publish logic is based on the per-row INSERT return value, not a post-hoc time-window query. Document this explicitly in the handler implementation.

Informational (No Action Required)

  1. Finding 2: Sequential ID exposure is acceptable for MVP given the 404-masking fix; defer non-sequential IDs to a follow-up story.
  2. Finding 5: WS auth pattern is correct; add integration tests.
  3. Finding 8: Self-assignment suppression has no security concern.
  4. Finding 9: CASCADE is correct; future ActorId FK must not use CASCADE.

Verdict

Approved with required changes.

Two items (Findings 1 and 3) must be resolved during implementation before the branch is submitted for code review. The remaining items are improvements that should be addressed in the same story (Findings 4, 6, 7) or deferred with a recorded decision (Findings 2, 5, 8, 9).

**security** (`26_in_app_notifications_security.md`) # Security Pre-Review: Story `#26` — In-App Notification Center **Reviewer:** Security Agent **Date:** 2026-07-09 **Design document:** `26_in_app_notifications_design.md` **Status:** Approved with required changes --- ## Summary The overall design is architecturally sound. Authorization scoping for the query and bulk-mark-all paths is correct. The WebSocket gating pattern mirrors the existing publishers faithfully. Six issues are raised below; two are required changes that must be resolved before or during implementation, and four are lower-severity items with clear mitigations. --- ## Findings ### 1. IDOR / Authorization — `MarkNotificationReadCommand` **Severity: High** The handler correctly performs an ownership check (`entity.RecipientId != currentUserId → throw UnauthorizedAccessException`). However, as the design itself flags, `NotificationId` is a sequential auto-increment integer. A motivated attacker who holds one valid `NotificationId` (from their own session) can enumerate adjacent IDs and invoke `MarkNotificationReadCommand` against them, probing for the existence of other users' notifications. The current exception mapping in `ExceptionHandler.cs` distinguishes cleanly: | Exception | HTTP status | |---|---| | `EntityNotFoundException` | 404 | | `UnauthorizedAccessException` | 403 | This means the handler's current design returns **403** when a user probes another user's notification that exists, which **confirms the notification ID is valid** for some user. An attacker can therefore distinguish "this ID exists but belongs to someone else" (403) from "this ID does not exist" (404). This is an oracle for notification existence. **Recommendation (required):** When `RecipientId != currentUserId`, throw `EntityNotFoundException` rather than `UnauthorizedAccessException`. The caller receives 404 in both the "does not exist" and "belongs to another user" cases, eliminating the enumeration oracle. The internal message can still be "not your notification" for logging purposes. Document this as a deliberate security decision in the handler comment. --- ### 2. Sequential `NotificationId` Exposure (Design Q5) **Severity: Medium** Related to Finding 1. Sequential integer PKs allow an authenticated user to infer the total notification volume of the system (by comparing the highest ID they have received with neighbouring IDs). While this does not directly expose content, it is an information leak about system activity. **Recommendation:** For this MVP, the 404-masking fix in Finding 1 is the mandatory control. Switching to a non-sequential ID type (e.g., ULID or UUID v7) would eliminate the enumeration vector entirely but is a scope change. It is acceptable to defer that to a follow-up story, provided the 404-masking is in place at launch. --- ### 3. Information Leakage — `NotificationDto` exposes `RecipientId` over WebSocket **Severity: Medium** `NotificationDto` is defined as: ```csharp public record NotificationDto( NotificationId Id, NotificationKind Kind, string Body, TodoListId TodoListId, TodoListTitle TodoListTitle, TodoNr? TodoNr, bool IsRead, DateTimeOffset CreatedAt); ``` The design document's §6.3 filter description implies the DTO carries `RecipientId` for WS routing (`change.Data!.RecipientId == userId.Value`). However, the `NotificationDto` record as defined does **not** include `RecipientId`. The design text is inconsistent: §6.3 references `change.Data!.RecipientId` as the filter field, but the DTO definition in §3.1 has no such field. If `RecipientId` is added to `NotificationDto` to enable the WS filter, it will be serialised into the JSON payload sent to the client. The client already knows their own user ID so this is not a cross-user leak, but it does expose an internal database integer (the user's primary key) unnecessarily. There are two clean alternatives: 1. **Preferred:** Store `RecipientId` in a parallel wrapper type that is used only for in-process routing and never serialised. The WS publisher holds `notificationChanges` as `IObservable<Change<NotificationId, NotificationDto>>` — this type carries the DTO. A lightweight approach is to use a separate `IObservable<(UserId RecipientId, Change<NotificationId, NotificationDto>)>` internally so the recipient is routed without embedding it in the DTO. 2. **Acceptable for MVP:** Add `RecipientId` to `NotificationDto` but annotate it with `[JsonIgnore]` (System.Text.Json) so it is used for in-process routing but not sent over the wire or returned by the REST queries. Ensure the JSON serialiser options on the WS send path respect `[JsonIgnore]` (the existing `JsonOptions` in `UserScopedTodoListChangePublisher` do not configure `DefaultIgnoreCondition`, so `[JsonIgnore]` will be respected). **Recommendation (required):** Choose one of the two approaches above and document it in the implementation. The WS payload sent to the browser must not contain `RecipientId`. --- ### 4. Notification Body Injection — `TodoListTitle` Lacks a Length Cap **Severity: Medium** The `Body` field is constructed server-side from persisted data: ```csharp var body = $"You were added to '{listTitle}'"; // AcceptListInvitation var body = $"'{dto.Title.Value}' was assigned to you"; // AssignTodo // Background job: "<title> is due today" / "<title> is overdue" ``` **`TodoTitle`** (in `Common/Types/TodoTitle.cs`) is validated with a hard cap of 1,024 characters — safe. **`TodoListTitle`** (in `Common/Types/TodoListTitle.cs`) validates only that the value is non-null. There is **no length cap**. A user who creates a list with a very long title (e.g., 10,000 characters) causes the persisted `Body` field to be arbitrarily large. This has two secondary consequences: 1. The `Body` column has no `MaxLength` configured in the EF Core entity (`builder.Property(x => x.Kind).HasConversion<string>()` is the only property annotation shown; `Body` gets the default `text` column type in PostgreSQL, which accepts up to 1 GB). 2. The WS payload and REST response can carry an unexpectedly large string, which could degrade client performance and consume memory. There is no HTML or SQL injection risk here because the value is stored as plain text and never rendered as raw HTML server-side, and EF Core parameterises all values. The risk is a DB storage and wire-size concern. **Recommendation:** Add a length cap to `TodoListTitle.Validate()` — 255 characters is conventional for a list title and consistent with UX constraints. Additionally, add `builder.Property(x => x.Body).HasMaxLength(2048)` (or a derived maximum that accounts for the longest possible template with the longest valid title) to the `NotificationEntity` configuration so the DB enforces the cap as a second line of defence. --- ### 5. WebSocket Authentication — New Endpoint Correctly Follows Established Pattern **Severity: Info** `UserScopedTodoListChangePublisher.cs` (the reference implementation) performs the following sequence before accepting the WebSocket: 1. Check `context.WebSockets.IsWebSocketRequest` → 400 if false. 2. Call `getUserIdHandler.Handle(new GetCurrentUserIdQuery(), ...)` → 401 if null. 3. Only then call `context.WebSockets.AcceptWebSocketAsync()`. This order is critical: accepting the WebSocket before verifying the user would allow an unauthenticated connection to be upgraded and then terminated abortly, wasting server resources and potentially leaking connection state. The design for `UserScopedNotificationPublisher` (§6.3) shows the same correct order (`userId is null → StatusCode = 401; return`). As long as the implementation follows this pattern exactly — which the existing publisher code makes clear — the new endpoint is safe. **Recommendation:** No change required. Add a unit test that verifies a non-WebSocket request to `/api/changes/NotificationDto` gets 400, and an unauthenticated WebSocket upgrade attempt gets 401, consistent with the existing WS publisher tests (if any exist). --- ### 6. Rate Limiting — No Existing Middleware; New Endpoints Are Unprotected **Severity: Medium** A search of the codebase finds no rate limiting middleware, no `AddRateLimiter` / `UseRateLimiter` call, no `AspNetCoreRateLimit` package reference, and no per-endpoint throttle attribute anywhere in the project. This is a pre-existing gap, not introduced by this story. The new queries `GetNotificationsForCurrentUserQuery` and `GetUnreadNotificationCountForCurrentUserQuery` are routed via `MapRequests`, which maps all public `IRequest` types to `POST /api/{TypeName}`. Both queries hit the DB and are callable in a tight loop by an authenticated user. `GetNotificationsForCurrentUserQuery` already caps `Limit` at 200 rows (design §5.1), which limits per-request DB cost. The count query is a single `COUNT(*)` over an indexed column. **Recommendation:** The absence of rate limiting is a pre-existing gap that is out of scope for this story to fix. However, the team should track it as a separate security backlog item. For this story specifically, ensure the `Limit` parameter validation (max 200) is enforced by a guard in the handler body — e.g., `if (request.Limit > 200) throw new ArgumentException(...)` — rather than relying on the frontend to send a sane value. --- ### 7. Multi-Replica Duplicate WS Publish — Affected-Row Count Mitigation Is Correct **Severity: Info / Confirmed** Design Q7 asks whether checking the affected-row count from `ExecuteSqlInterpolatedAsync` is sufficient to suppress duplicate WS publishes when multiple replicas run `DueNotificationJob` concurrently. **Confirmed: yes, this is the correct mitigation.** `ExecuteSqlInterpolatedAsync` returns the number of rows affected. For an `INSERT ... ON CONFLICT DO NOTHING` statement, the return value is: - `1` if the row was inserted (this replica wins; it should publish). - `0` if the conflict fired and nothing was inserted (another replica already inserted; skip the publish). Because the unique constraint `UX_NotificationEntity_IdempotencyKey` is enforced by PostgreSQL atomically, exactly one replica will see a return value of 1 for any given `(RecipientId, TodoListId, TodoNr, Kind, DATE(CreatedAt))` tuple. Only that replica should call `notificationSubject.OnNext(...)`. **Implementation note:** The design describes fetching inserted IDs "with a short time window" after the bulk insert. This approach is fragile in a multi-replica scenario because both replicas may query the table and find the newly inserted rows, causing both to publish. The recommended approach is to batch the inserts and check the return value of each individual `INSERT ... ON CONFLICT DO NOTHING` call, publishing only when the return is 1. If a true bulk insert (multiple rows in a single SQL statement) is used, the total return value must equal the expected insert count; any shortfall means some conflicts occurred and those specific rows must not be republished. The implementation must be careful here — a naive "fetch rows inserted in the last N seconds" approach will produce duplicate WS events. --- ### 8. Self-Assignment Suppression (PO Decision Q2) — Security Implications **Severity: Info** The PO has decided: if `request.AssigneeId == currentUserId`, no notification is created. This is implemented by comparing the current user's ID against the assignee ID before calling `CreateNotificationCommand`. From a security standpoint, this check is straightforward and benign. There is no TOCTOU risk: the `currentUserId` is resolved from the session inside the same request scope. The only edge case is an authorisation-level concern: the `AssignTodoCommandHandler` already enforces that the assignee is a member of the list (`isMember` check), so a user cannot self-assign to a list they don't belong to, and they certainly cannot assign to someone else without that person being a member. The suppression logic introduces no new attack surface. **Recommendation:** No security concern. Implement as designed. Add a unit test asserting no notification row is created when `assigneeId == currentUserId`. --- ### 9. ON DELETE CASCADE — Both FK Constraints **Severity: Low** Both `RecipientId → UserEntity(Id)` and `TodoListId → TodoListEntity(Id)` use `ON DELETE CASCADE`. This means: - **User deleted:** All their notification rows vanish. Correct and expected; a deleted user's data should not linger. - **List deleted:** All notification rows referencing that list vanish. Correct; a deleted list's "you were added" and assignment notifications are meaningless artefacts. One subtle concern: if a user is deleted while another user's notification references the deleted user as an actor (e.g., "Alex added you to 'X'"), that notification also vanishes because `RecipientId` is the joining user's ID — **not** the actor's ID. There is no FK on the inviter or assigner. This is correct by the schema design. However, if in a future story the inviter's name is added to the `Body` or a new `ActorId` FK column is introduced pointing to `UserEntity`, that FK should use `ON DELETE SET NULL` or `ON DELETE RESTRICT`, not CASCADE, to avoid silently deleting the recipient's notification when an unrelated user (the actor) is deleted. **Recommendation:** No change required for this story. Add a comment in the migration and entity configuration noting that any future `ActorId` FK must not use CASCADE. --- ## Authorization Model Summary Assessment | Operation | Authentication | Ownership / Scope | Assessment | |---|---|---|---| | `GetNotificationsForCurrentUserQuery` | Required | WHERE scoped to `currentUserId` | Correct | | `GetUnreadNotificationCountForCurrentUserQuery` | Required | COUNT scoped to `currentUserId` | Correct | | `MarkNotificationReadCommand` | Required | Ownership check in handler body | Correct logic; fix exception type (Finding 1) | | `MarkAllNotificationsReadCommand` | Required | ExecuteUpdate scoped to `currentUserId` | Correct | | `CreateNotificationCommand` (internal) | N/A (not HTTP-exposed) | Caller-supplied `RecipientId` from trusted handlers/job | Acceptable | | `CreateDueNotificationsCommand` (internal) | N/A (job context) | `RecipientId` taken from DB rows (AssigneeId) | Correct | | WS `/api/changes/NotificationDto` | 401 before upgrade | Filter `change.Data.RecipientId == userId` | Correct (see Finding 3 re: DTO field) | --- ## Required Changes Before or During Implementation 1. **Finding 1 (High):** In `MarkNotificationReadCommandHandler`, change `throw new UnauthorizedAccessException(...)` to `throw EntityNotFound.Create<NotificationEntity>(...)` when `RecipientId != currentUserId`. This eliminates the 403-vs-404 enumeration oracle. 2. **Finding 3 (Medium):** Resolve the `RecipientId` field ambiguity. Either use a wrapper observable type that carries recipient routing data outside the serialised DTO, or add `[JsonIgnore]` to `RecipientId` if it must live on `NotificationDto`. The JSON payload sent to the browser must not include `RecipientId`. ## Recommended Changes (Should Fix) 3. **Finding 4 (Medium):** Add a length cap to `TodoListTitle.Validate()` (suggested: 255 characters). Add `HasMaxLength` to the `Body` property in `NotificationEntity` EF Core configuration. 4. **Finding 6 (Medium):** Enforce the `Limit <= 200` constraint in the handler body with an `ArgumentException`, not just as a documented convention. 5. **Finding 7 (Info/Confirmed):** Ensure the multi-replica WS publish logic is based on the per-row `INSERT` return value, not a post-hoc time-window query. Document this explicitly in the handler implementation. ## Informational (No Action Required) 6. **Finding 2:** Sequential ID exposure is acceptable for MVP given the 404-masking fix; defer non-sequential IDs to a follow-up story. 7. **Finding 5:** WS auth pattern is correct; add integration tests. 8. **Finding 8:** Self-assignment suppression has no security concern. 9. **Finding 9:** CASCADE is correct; future `ActorId` FK must not use CASCADE. --- ## Verdict **Approved with required changes.** Two items (Findings 1 and 3) must be resolved during implementation before the branch is submitted for code review. The remaining items are improvements that should be addressed in the same story (Findings 4, 6, 7) or deferred with a recorded decision (Findings 2, 5, 8, 9).
lena commented 2026-08-18 13:12:56 +02:00 (Migrated from git.butzei.de)

design (26_in_app_notifications_design.md)

Design: Story #26 — In-App Notification Center

Status: Ready for Security pre-review
Author: Software Architect
Date: 2026-07-09


1. Summary

A NotificationEntity table records per-user notifications that are created as side effects inside existing CQS handlers and a new background job. Notifications are delivered to connected clients in real-time over a new user-scoped WebSocket channel (/api/changes/NotificationDto) that reuses the established ISubject<Change<TId,TDto>> pattern. The frontend polls nothing; the bell badge stays live as long as the WebSocket is open.

Four events produce notifications:

Trigger Text template Recipient
User accepts a list invitation "Alex added you to 'Groceries'" Joining user
Todo is assigned to a user "'Buy milk' was assigned to you" Assignee
Assigned todo's due date equals today (background job) "'Buy milk' is due today" Assignee
Assigned, incomplete todo's due date has passed (background job) "'Buy milk' is overdue" Assignee

Notifications persist in PostgreSQL and survive session loss. A daily background job creates due-today and overdue notifications and also purges rows older than 30 days.


2. DB Schema Changes

2.1 New table: NotificationEntity

CREATE TABLE "NotificationEntity" (
    "Id"          integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    "RecipientId" integer NOT NULL REFERENCES "UserEntity"("Id") ON DELETE CASCADE,
    "TodoListId"  integer NOT NULL REFERENCES "TodoListEntity"("Id") ON DELETE CASCADE,
    "TodoNr"      integer NULL,        -- null for list-level notifications
    "Kind"        text    NOT NULL,    -- enum stored as string (see below)
    "Body"        text    NOT NULL,    -- rendered message text
    "IsRead"      boolean NOT NULL DEFAULT false,
    "CreatedAt"   timestamptz NOT NULL DEFAULT now()
);

Indexes:

-- Primary lookup: unread notifications for a user, newest first
CREATE INDEX "IX_NotificationEntity_RecipientId_CreatedAt"
    ON "NotificationEntity" ("RecipientId", "CreatedAt" DESC);

-- Idempotency guard for the background job (see §7)
CREATE UNIQUE INDEX "UX_NotificationEntity_IdempotencyKey"
    ON "NotificationEntity" ("RecipientId", "TodoListId", "TodoNr", "Kind", DATE("CreatedAt" AT TIME ZONE 'UTC'))
    WHERE "TodoNr" IS NOT NULL;

The idempotency unique index covers only rows with a TodoNr (i.e., due-today and overdue kinds) so the background job cannot insert a duplicate for the same todo on the same UTC calendar day. For list-level and assignment notifications (inserted by handlers, never by the job) no uniqueness constraint is applied because the handler path is already idempotent by the nature of those operations (invitation accepted once, assignment set once).

ON DELETE CASCADE on both FKs: if a list or a user is deleted the notification rows disappear automatically without application code. This is the right behaviour — a deleted list's notifications are meaningless.

Kind enum values (stored as string, matches the C# enum):

Value Meaning
AddedToList User was added to a list
TodoAssigned A todo was assigned to the user
TodoDueToday An assigned todo is due today
TodoOverdue An assigned todo is past its due date

2.2 EF Core entity

// CqsTodo/Entities/NotificationEntity.cs
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace CqsTodo.Entities;

public class NotificationEntity : IEntity<NotificationEntity>
{
    public NotificationId Id { get; set; } = NotificationId.From(0);
    public UserId RecipientId { get; set; }
    public UserEntity Recipient { get; set; } = null!;
    public TodoListId TodoListId { get; set; }
    public TodoListEntity TodoList { get; set; } = null!;
    public TodoNr? TodoNr { get; set; }          // null for list-level kinds
    public NotificationKind Kind { get; set; }
    public string Body { get; set; } = string.Empty;
    public bool IsRead { get; set; }
    public DateTimeOffset CreatedAt { get; set; }

    public void Configure(EntityTypeBuilder<NotificationEntity> builder)
    {
        builder.HasKey(x => x.Id);
        builder.Property(x => x.Id).HasIdentityVogenColumn();
        builder.HasOne(x => x.Recipient)
               .WithMany()
               .HasForeignKey(x => x.RecipientId)
               .OnDelete(DeleteBehavior.Cascade);
        builder.HasOne(x => x.TodoList)
               .WithMany()
               .HasForeignKey(x => x.TodoListId)
               .OnDelete(DeleteBehavior.Cascade);
        builder.Property(x => x.Kind).HasConversion<string>();
        builder.HasIndex(x => new { x.RecipientId, x.CreatedAt });
        // Idempotency index expressed in Fluent API via raw SQL annotation — see migration note
    }
}

Note on the unique idempotency index: EF Core does not support filtered unique indexes with an expression predicate (the WHERE "TodoNr" IS NOT NULL clause) through the Fluent API. The index is therefore created with a raw SQL statement inside the migration's Up() method rather than via builder.HasIndex(...).

2.3 New enum

// CqsTodo/Entities/NotificationKind.cs
namespace CqsTodo.Entities;

public enum NotificationKind
{
    AddedToList,
    TodoAssigned,
    TodoDueToday,
    TodoOverdue,
}

2.4 New Vogen value object

// Common/Types/NotificationId.cs
using Vogen;

namespace Common.Types;

[ValueObject<int>]
public partial struct NotificationId : IValueObject<NotificationId, int>
{
    public static Validation Validate(int value)
        => value >= 0 ? Validation.Ok : Validation.Invalid("Must be 0 or positive value.");
}

NotificationId must also be registered in CqsTodo/DbContext/VogenEfCoreConverters.cs:

[EfCoreConverter<NotificationId>]

2.5 Migration name

AddNotificationEntity


3. DTO Changes

3.1 New NotificationDto in Common/Dtos/

// Common/Dtos/NotificationDto.cs
using Common.Types;
using CqsTodo.Entities;

namespace Common.Dtos;

public record NotificationDto(
    NotificationId Id,
    NotificationKind Kind,
    string Body,
    TodoListId TodoListId,
    TodoListTitle TodoListTitle,
    TodoNr? TodoNr,
    bool IsRead,
    DateTimeOffset CreatedAt);

The TodoListTitle is denormalised into the DTO so the frontend can display the list name without a second round-trip. The TodoNr is included so the frontend can deep-link to the specific item when present.


4. Commands

4.1 MarkNotificationReadCommand

// CqsTodo/Features/Notifications/MarkNotificationReadCommandHandler.cs
public record MarkNotificationReadCommand(NotificationId NotificationId) : IRequest<Unit>;

Handler logic:

  1. Load the NotificationEntity by NotificationId.
  2. If not found, throw EntityNotFoundException.
  3. If entity.RecipientId != currentUserId throw UnauthorizedAccessException ("Not your notification").
  4. ExecuteUpdateAsync to set IsRead = true.
  5. Return Unit.Default.

No WS publish needed — the badge update is a client-side optimistic decrement on click (the notification list is re-fetched or the local Zustand store is patched).

Authorization:

public static void ConfigureDi(IHandlerRegistrationConfig<MarkNotificationReadCommand> config)
    => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery());

The ownership guard (step 3) is handled in the handler body, not as a separate AuthorizeIsCurrentUserAuthenticated decorator, because the authorization depends on loading the entity first.

4.2 MarkAllNotificationsReadCommand

// CqsTodo/Features/Notifications/MarkAllNotificationsReadCommandHandler.cs
public record MarkAllNotificationsReadCommand : IRequest<Unit>;

Handler logic:

  1. Get currentUserId from GetCurrentUserIdQuery.
  2. ExecuteUpdateAsync all NotificationEntity rows where RecipientId == currentUserId && IsRead == false, setting IsRead = true.
  3. Return Unit.Default.

Authorization:

public static void ConfigureDi(IHandlerRegistrationConfig<MarkAllNotificationsReadCommand> config)
    => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery());

4.3 Internal CreateNotificationCommand (not exposed as an API endpoint)

This is an internal command used by the notification-creating handlers and the background job. It is not registered via MapRequests (it is not public) and has no HTTP endpoint.

// CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs
internal record CreateNotificationCommand(
    UserId RecipientId,
    TodoListId TodoListId,
    TodoNr? TodoNr,
    NotificationKind Kind,
    string Body)
    : IRequest<NotificationDto>;

Handler logic:

  1. Insert a new NotificationEntity using the supplied parameters and CreatedAt = DateTimeOffset.UtcNow.
  2. Re-read via a fresh DbContext (per established pattern) to obtain the generated Id and joined TodoListTitle.
  3. Publish notificationSubject.OnNext(new Change<NotificationId, NotificationDto>(ChangeReason.Add, dto.Id, dto)).
  4. Return the NotificationDto.

Because CreateNotificationCommand is internal, MapRequests skips it (it only maps public IRequest types).

No ConfigureDi: This handler is registered directly with [SetupHandler] and does not use IHaveDiConfig because it is not externally callable; authorization is enforced by the calling handlers.

Injection into calling handlers:

IHandler<CreateNotificationCommand, NotificationDto> createNotificationHandler

5. Queries

5.1 GetNotificationsForCurrentUserQuery

// CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs
public record GetNotificationsForCurrentUserQuery(
    bool UnreadOnly = false,
    int Limit = 50)
    : IRequest<IReadOnlyCollection<NotificationDto>>;

Handler logic:

  1. Get currentUserId from GetCurrentUserIdQuery.
  2. Query NotificationEntity where RecipientId == currentUserId, ordered by CreatedAt DESC, limited to Limit rows.
  3. If UnreadOnly == true, add .Where(x => !x.IsRead).
  4. Join TodoListEntity to get Title for the DTO.
  5. Project to NotificationDto (inline select, no Mapperly needed for this simple projection).
return await dbContext.Set<NotificationEntity>()
    .Where(x => x.RecipientId == currentUserId)
    .Where(x => !request.UnreadOnly || !x.IsRead)
    .OrderByDescending(x => x.CreatedAt)
    .Take(request.Limit)
    .Select(x => new NotificationDto(
        x.Id,
        x.Kind,
        x.Body,
        x.TodoListId,
        x.TodoList.Title,
        x.TodoNr,
        x.IsRead,
        x.CreatedAt))
    .ToArrayAsync(cancellationToken);

Return type: IReadOnlyCollection<NotificationDto>

Authorization:

public static void ConfigureDi(IHandlerRegistrationConfig<GetNotificationsForCurrentUserQuery> config)
    => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery());

Note on Limit: The frontend uses a fixed default of 50. The Limit parameter is validated to a maximum of 200 on the handler side to prevent DoS via large page fetches. Pagination is out of scope for this story.

5.2 GetUnreadNotificationCountForCurrentUserQuery

public record GetUnreadNotificationCountForCurrentUserQuery : IRequest<int>;

Handler logic: COUNT(*) where RecipientId == currentUserId && !IsRead. Used to populate the badge count on initial page load and after reconnect.

Authorization: AuthorizeIsCurrentUserAuthenticatedQuery.


6. Real-Time Delivery

6.1 Chosen approach

A new user-scoped WebSocket publisher (UserScopedNotificationPublisher) is added at /api/changes/NotificationDto. This follows exactly the same pattern as UserScopedTodoChangePublisher and UserScopedTodoListChangePublisher. The subject is ISubject<Change<NotificationId, NotificationDto>>.

Notifications are inherently user-scoped (each notification has a single recipient), so a global broadcast subject filtered by RecipientId is all that is needed. No membership set tracking is required for this subject — notifications are not filtered by list access.

6.2 Subject registration in CqsTodo/Setup.cs

.AddSingleton<ISubject<Change<NotificationId, NotificationDto>>>(
    new Subject<Change<NotificationId, NotificationDto>>())
.AddSingleton<IObservable<Change<NotificationId, NotificationDto>>>(p
    => p.GetRequiredService<ISubject<Change<NotificationId, NotificationDto>>>().AsObservable())

6.3 UserScopedNotificationPublisher

// CqsTodo.WebApi/WebSocket/UserScopedNotificationPublisher.cs
public partial class UserScopedNotificationPublisher(
    IObservable<Change<NotificationId, NotificationDto>> notificationChanges,
    IHandler<GetCurrentUserIdQuery, UserId?> getUserIdHandler,
    ILogger<UserScopedNotificationPublisher> logger)
{
    public async Task Subscribe(HttpContext context)
    {
        // ... standard WS setup (see UserScopedTodoChangePublisher for exact pattern) ...

        var userId = await getUserIdHandler.Handle(...);
        if (userId is null) { context.Response.StatusCode = 401; return; }

        using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
        var sendSemaphore = new SemaphoreSlim(1, 1);

        var notifSub = notificationChanges
            .Where(change => change.Data?.RecipientId == userId.Value
                          || change.Id == /* check by recipient stored in a local lookup */ ...)
            ...
    }
}

Recipient filter detail: Change<NotificationId, NotificationDto> carries the full NotificationDto as Data. The filter is simply .Where(change => change.Data!.RecipientId == userId.Value). Because ChangeReason.Add always has non-null Data, and notification deletes are not broadcast, Data can be assumed non-null.

However, since NotificationDto contains RecipientId already, the publisher checks change.Data?.RecipientId == userId.Value to route the event only to the intended recipient's WebSocket connection.

6.4 Endpoint registration in Program.cs

app.Map(
    "/api/changes/NotificationDto",
    async context
        => await context.RequestServices.GetRequiredService<UserScopedNotificationPublisher>()
            .Subscribe(context));
// In ConfigureServices
.AddTransient<UserScopedNotificationPublisher>()

6.5 Why a new WS endpoint rather than piggybacking

The existing /api/changes/TodoDto and /api/changes/TodoListDto channels are list-scoped; they carry Change<TodoId, TodoDto> and Change<TodoListId, TodoListBroadcastDto> respectively. Squeezing notification events onto one of those channels would require a discriminated union or an envelope type — a cross-cutting structural change. A new endpoint is cheaper, keeps each channel strongly typed, and the WebSocket handshake cost is negligible for a logged-in user who holds two or three connections already.


7. Background Job — Due-Today and Overdue Notifications

7.1 Scope

The background job handles only TodoDueToday and TodoOverdue notifications. AddedToList and TodoAssigned notifications are always created synchronously inside their respective command handlers (§4.3).

7.2 Implementation approach

Use PeriodicTimer inside a BackgroundService (ASP.NET Core IHostedService subclass). No external scheduler library (Hangfire, Quartz) is needed; PeriodicTimer was introduced in .NET 6 specifically for this pattern and avoids thread pool waste.

// CqsTodo/BackgroundJobs/DueNotificationJob.cs
internal sealed class DueNotificationJob(
    IServiceScopeFactory scopeFactory,
    ILogger<DueNotificationJob> logger) : BackgroundService
{
    // Run once per day, shortly after midnight UTC
    private static readonly TimeSpan Period = TimeSpan.FromHours(24);
    // Initial delay: time until next 00:05 UTC
    // Computed dynamically in ExecuteAsync

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var initialDelay = ComputeDelayToNextRunUtc();
        await Task.Delay(initialDelay, stoppingToken);

        using var timer = new PeriodicTimer(Period);
        do
        {
            try
            {
                await RunAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "DueNotificationJob failed");
                // Continue; next tick will retry
            }
        } while (await timer.WaitForNextTickAsync(stoppingToken));
    }

    private static TimeSpan ComputeDelayToNextRunUtc()
    {
        var now = DateTimeOffset.UtcNow;
        var nextRun = now.Date.AddDays(1).AddMinutes(5); // 00:05 UTC next day
        return nextRun - now;
    }

    private async Task RunAsync(CancellationToken cancellationToken)
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var handler = scope.ServiceProvider
            .GetRequiredService<IHandler<CreateDueNotificationsCommand, Unit>>();
        await handler.Handle(new CreateDueNotificationsCommand(), cancellationToken);
    }
}

Registration in Setup.cs (or in Program.cs via builder.Services):

services.AddHostedService<DueNotificationJob>();

7.3 CreateDueNotificationsCommand (internal, not HTTP-exposed)

// CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs
internal record CreateDueNotificationsCommand : IRequest<Unit>;

Handler logic:

today = DateOnly.FromDateTime(DateTime.UtcNow)

dueTodayTodos = TodoEntity WHERE DueDate == today
                              AND AssigneeId IS NOT NULL
                              AND DoneDate IS NULL

overdueTodos  = TodoEntity WHERE DueDate <  today
                              AND AssigneeId IS NOT NULL
                              AND DoneDate IS NULL

For each todo in dueTodayTodos:
    INSERT INTO NotificationEntity (RecipientId, TodoListId, TodoNr, Kind, Body, IsRead, CreatedAt)
    VALUES (AssigneeId, TodoListId, Nr, 'TodoDueToday', '<title> is due today', false, NOW())
    ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey" DO NOTHING

For each todo in overdueTodos:
    INSERT INTO NotificationEntity (RecipientId, TodoListId, TodoNr, Kind, Body, IsRead, CreatedAt)
    VALUES (AssigneeId, TodoListId, Nr, 'TodoOverdue', '<title> is overdue', false, NOW())
    ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey" DO NOTHING

For each successfully inserted notification:
    publish Change<NotificationId, NotificationDto>(ChangeReason.Add, ...) on notificationSubject

Because the UX_NotificationEntity_IdempotencyKey unique index includes DATE(CreatedAt AT TIME ZONE 'UTC'), re-running the job on the same calendar day (e.g., after a crash and restart) produces ON CONFLICT ... DO NOTHING for already-created rows. The job therefore never double-notifies.

Implementation note on upsert: EF Core 10 supports ExecuteInsertAsync with conflict resolution via UseStrategy(InsertConflictResolution.DoNothing) or a raw SQL interpolated string. The raw SQL approach is preferred here because the InsertAsync EF Core API does not yet surface per-column conflict targets:

await dbContext.Database.ExecuteSqlInterpolatedAsync($"""
    INSERT INTO "NotificationEntity"
        ("RecipientId","TodoListId","TodoNr","Kind","Body","IsRead","CreatedAt")
    VALUES
        ({assigneeId.Value},{listId.Value},{nr.Value},'TodoDueToday',{body},false,now())
    ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey"
    DO NOTHING
    """, cancellationToken);

The inserted IDs are then fetched back (query the rows just inserted with a short time window) and published.

Alternative considered: Insert via dbContext.Add(entity) inside a try/catch for UniqueConstraintException. Rejected because: (a) it generates one round-trip per todo, (b) exception-based flow control is slower and noisy in logs. The bulk ON CONFLICT DO NOTHING approach is cleaner.

7.4 Purge strategy (see also §8)

The CreateDueNotificationsCommand handler also runs the 30-day purge in the same transaction/scope:

var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
await dbContext.Set<NotificationEntity>()
    .Where(x => x.CreatedAt < cutoff)
    .ExecuteDeleteAsync(cancellationToken);

Bundling purge with the notification job keeps the codebase simple — no separate hosted service needed.


8. 30-Day Purge Strategy

As noted in §7.4, purge is executed once per day inside CreateDueNotificationsCommand. The ExecuteDeleteAsync call targets CreatedAt < (UtcNow - 30 days) and uses a single bulk-delete SQL statement, which is efficient even for large tables (the index on RecipientId, CreatedAt covers the range scan).

If the application is restarted or the job fails, rows may accumulate beyond 30 days until the next successful run. This is acceptable for MVP (a cosmetic worst-case of one extra day of stale rows).

No separate purge command, endpoint, or hosted service is added.


9. Trigger Points — Handler Modifications

9.1 AcceptListInvitationCommandHandler — "Added to list" notification

After the membership is saved and the list DTO is fetched, inject and call:

var listTitle = dto.Title.Value;   // e.g., "Groceries"
// The inviter's display name is not easily available here; use "You were added" wording
var body = $"You were added to '{listTitle}'";

await createNotificationHandler.Handle(
    new CreateNotificationCommand(
        RecipientId: userId!.Value,
        TodoListId: invitation.TodoListId,
        TodoNr: null,
        Kind: NotificationKind.AddedToList,
        Body: body),
    cancellationToken);

Inviter name: The TodoListInvitationEntity does not record who created the invitation. Retrieving the inviter would require joining through the TodoListToUserEntity owner record. For MVP, the notification body is "You were added to 'X'" without naming the inviter. If the inviter's name is needed in the future, TodoListInvitationEntity can gain a CreatedByUserId column. This is left as an open question (see §11).

9.2 AssignTodoCommandHandler — "Todo assigned" notification

After publishing the todo change event, and only when request.AssigneeId is non-null (a clear-assignment produces no notification):

if (request.AssigneeId is not null)
{
    var body = $"'{dto.Title.Value}' was assigned to you";
    await createNotificationHandler.Handle(
        new CreateNotificationCommand(
            RecipientId: request.AssigneeId.Value,
            TodoListId: request.TodoListId,
            TodoNr: request.Nr,
            Kind: NotificationKind.TodoAssigned,
            Body: body),
        cancellationToken);
}

Self-assignment edge case: If the current user assigns a todo to themselves, a notification is still created. The story AC does not exclude self-assignment notifications; this is the simplest behaviour and consistent with the other notification types.


10. Authorization Summary

Operation Handler Required authorization
Get notifications (own) GetNotificationsForCurrentUserQueryHandler Authenticated (AuthorizeIsCurrentUserAuthenticatedQuery)
Get unread count GetUnreadNotificationCountForCurrentUserQueryHandler Authenticated
Mark one notification read MarkNotificationReadCommandHandler Authenticated + ownership check in handler body
Mark all notifications read MarkAllNotificationsReadCommandHandler Authenticated
Create notification (internal) CreateNotificationCommandHandler Not exposed via HTTP; no decorator needed
Create due notifications (job) CreateDueNotificationsCommandHandler Not exposed via HTTP; runs in IHostedService scope
WS subscribe /api/changes/NotificationDto UserScopedNotificationPublisher 401 if GetCurrentUserIdQuery returns null (same pattern as other WS publishers)

Security notes for pre-review:

  1. Ownership check for MarkNotificationReadCommand: A user must not be able to mark another user's notification as read. The handler must load the entity and compare RecipientId == currentUserId before updating. This check MUST NOT be skipped even though only the notification owner can know the NotificationId (IDs are sequential integers, trivially enumerable).
  2. GetNotificationsForCurrentUserQuery never leaks cross-user data: The WHERE clause always scopes by currentUserId; there is no NotificationId-based lookup that could return another user's row.
  3. WS filter: UserScopedNotificationPublisher filters on change.Data!.RecipientId == userId.Value. Data is always non-null for ChangeReason.Add events and notifications are only ever added (never updated or deleted via the WS subject). This is safe.
  4. Background job runs without HTTP context: CreateDueNotificationsCommand must NOT use GetCurrentUserIdQuery — it operates on behalf of all users. The job injects RecipientId directly from the scanned todo rows.
  5. Notification body is rendered server-side: The Body string is constructed in C# from data already in the DB (list title, todo title). It is not constructed from user-supplied free text except through those already-validated fields. No injection risk beyond what those fields already carry.

11. Files That Will Change / New Files

Backend — new files

File Purpose
Common/Types/NotificationId.cs Vogen VO for notification PK
Common/Dtos/NotificationDto.cs DTO returned to frontend and broadcast over WS
CqsTodo/Entities/NotificationEntity.cs EF Core entity + IEntity<T> configuration
CqsTodo/Entities/NotificationKind.cs Enum for notification type
CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs Internal command: insert + publish
CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs Internal command: bulk insert due/overdue + purge
CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs Query: fetch notification list
CqsTodo/Features/Notifications/GetUnreadNotificationCountForCurrentUserQueryHandler.cs Query: badge count
CqsTodo/Features/Notifications/MarkNotificationReadCommandHandler.cs Command: mark one read
CqsTodo/Features/Notifications/MarkAllNotificationsReadCommandHandler.cs Command: mark all read
CqsTodo/BackgroundJobs/DueNotificationJob.cs BackgroundService hosting the periodic timer
CqsTodo.WebApi/WebSocket/UserScopedNotificationPublisher.cs WS publisher filtered by RecipientId
CqsTodo/Migrations/<timestamp>_AddNotificationEntity.cs EF Core migration

Backend — files that change

File Change
CqsTodo/DbContext/VogenEfCoreConverters.cs Add [EfCoreConverter<NotificationId>]
CqsTodo/Setup.cs Register ISubject<Change<NotificationId,NotificationDto>> and its observable
CqsTodo.WebApi/Program.cs Register UserScopedNotificationPublisher as transient; map /api/changes/NotificationDto; register DueNotificationJob via AddHostedService
CqsTodo/Features/TodoLists/AcceptListInvitationCommandHandler.cs Inject IHandler<CreateNotificationCommand, NotificationDto>; create AddedToList notification after membership save
CqsTodo/Features/Todos/AssignTodoCommandHandler.cs Inject IHandler<CreateNotificationCommand, NotificationDto>; create TodoAssigned notification after todo update

Test files — new

File Tests
CqsTodo.Tests/Features/Notifications/CreateNotificationCommandHandlerTests.cs Inserts row, publishes WS event, returns DTO with correct fields
CqsTodo.Tests/Features/Notifications/GetNotificationsForCurrentUserQueryHandlerTests.cs Returns own notifications; pagination; UnreadOnly filter; no cross-user leakage
CqsTodo.Tests/Features/Notifications/GetUnreadNotificationCountForCurrentUserQueryHandlerTests.cs Zero count when no unread; count increments; count excludes other users
CqsTodo.Tests/Features/Notifications/MarkNotificationReadCommandHandlerTests.cs Marks own notification read; rejects marking another user's notification (throws UnauthorizedAccessException)
CqsTodo.Tests/Features/Notifications/MarkAllNotificationsReadCommandHandlerTests.cs All own unread become read; other users' notifications untouched
CqsTodo.Tests/Features/Notifications/CreateDueNotificationsCommandHandlerTests.cs Due-today job creates TodoDueToday rows; overdue job creates TodoOverdue rows; idempotent on re-run same day; done todos skipped; unassigned todos skipped; purges rows older than 30 days
CqsTodo.Tests/Features/TodoLists/AcceptListInvitationCommandHandlerTests.cs Extend: notification created for joining user
CqsTodo.Tests/Features/Todos/AssignTodoCommandHandlerTests.cs Extend: notification created for assignee; no notification on clear; no notification on self-assignment (or assert one is created, depending on decision in §11 Q3)

Frontend — out of scope for this doc; components needed

  • NotificationBell — header icon + unread badge; opens NotificationPanel
  • NotificationPanel — dropdown/drawer listing NotificationDto[]; "Mark all as read" button
  • NotificationItem — renders body, list name, relative timestamp; navigates on click
  • Zustand store slice: notifications: NotificationDto[], unreadCount: number; WebSocket subscription on /api/changes/NotificationDto; actions: markRead(id), markAllRead(), fetchInitial()
  • API functions: getNotificationsForCurrentUser(), getUnreadNotificationCount(), markNotificationRead(id), markAllNotificationsRead()

12. Open Questions for Security Agent

PO decisions (resolved before security pre-review):

  • Q1 — Inviter name: Out of scope for this story. The wording "You were added to 'X'" is acceptable for MVP. Adding the inviter's name requires a new column on TodoListInvitationEntity and is a separate scope item.
  • Q2 — Self-assignment notification: Suppress it. If the current user assigns a todo to themselves, no notification is created (request.AssigneeId == currentUserId → skip). You already know you assigned it to yourself.
  • Q3 — Re-assignment stale notification: Accepted as-is. Alice's old TodoAssigned notification stays in her list after reassignment — this is fine for MVP. Alice was a member and saw the todo title; no new information is leaked.
  • Q4 — Unassignment notification: Out of scope for this story.

For Security Agent review:

  1. Sequential NotificationId enumeration: Should MarkNotificationReadCommand return 404 (not found) or 403 (forbidden) when a user probes another user's notification? Recommend 404 to avoid confirming existence of other users' notifications.

  2. Rate limiting: Is the existing API layer rate-limiting sufficient for GetNotificationsForCurrentUserQuery and GetUnreadNotificationCountForCurrentUserQuery? These are cheap but could be called in a tight loop.

  3. Multi-instance duplicate WS publish: In a multi-replica deployment, DueNotificationJob runs in every instance. The ON CONFLICT DO NOTHING prevents DB duplicates, but the WS publish would fire in every instance for the same notification. The handler must check the affected-row count from ExecuteSqlInterpolatedAsync — if 0 rows inserted (conflict), skip the publish. Confirm this is the correct mitigation.

**design** (`26_in_app_notifications_design.md`) # Design: Story `#26` — In-App Notification Center **Status:** Ready for Security pre-review **Author:** Software Architect **Date:** 2026-07-09 --- ## 1. Summary A `NotificationEntity` table records per-user notifications that are created as side effects inside existing CQS handlers and a new background job. Notifications are delivered to connected clients in real-time over a new user-scoped WebSocket channel (`/api/changes/NotificationDto`) that reuses the established `ISubject<Change<TId,TDto>>` pattern. The frontend polls nothing; the bell badge stays live as long as the WebSocket is open. Four events produce notifications: | Trigger | Text template | Recipient | |---|---|---| | User accepts a list invitation | "Alex added you to 'Groceries'" | Joining user | | Todo is assigned to a user | "'Buy milk' was assigned to you" | Assignee | | Assigned todo's due date equals today (background job) | "'Buy milk' is due today" | Assignee | | Assigned, incomplete todo's due date has passed (background job) | "'Buy milk' is overdue" | Assignee | Notifications persist in PostgreSQL and survive session loss. A daily background job creates due-today and overdue notifications and also purges rows older than 30 days. --- ## 2. DB Schema Changes ### 2.1 New table: `NotificationEntity` ```sql CREATE TABLE "NotificationEntity" ( "Id" integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "RecipientId" integer NOT NULL REFERENCES "UserEntity"("Id") ON DELETE CASCADE, "TodoListId" integer NOT NULL REFERENCES "TodoListEntity"("Id") ON DELETE CASCADE, "TodoNr" integer NULL, -- null for list-level notifications "Kind" text NOT NULL, -- enum stored as string (see below) "Body" text NOT NULL, -- rendered message text "IsRead" boolean NOT NULL DEFAULT false, "CreatedAt" timestamptz NOT NULL DEFAULT now() ); ``` **Indexes:** ```sql -- Primary lookup: unread notifications for a user, newest first CREATE INDEX "IX_NotificationEntity_RecipientId_CreatedAt" ON "NotificationEntity" ("RecipientId", "CreatedAt" DESC); -- Idempotency guard for the background job (see §7) CREATE UNIQUE INDEX "UX_NotificationEntity_IdempotencyKey" ON "NotificationEntity" ("RecipientId", "TodoListId", "TodoNr", "Kind", DATE("CreatedAt" AT TIME ZONE 'UTC')) WHERE "TodoNr" IS NOT NULL; ``` The idempotency unique index covers only rows with a `TodoNr` (i.e., due-today and overdue kinds) so the background job cannot insert a duplicate for the same todo on the same UTC calendar day. For list-level and assignment notifications (inserted by handlers, never by the job) no uniqueness constraint is applied because the handler path is already idempotent by the nature of those operations (invitation accepted once, assignment set once). **ON DELETE CASCADE** on both FKs: if a list or a user is deleted the notification rows disappear automatically without application code. This is the right behaviour — a deleted list's notifications are meaningless. **`Kind` enum values** (stored as string, matches the C# enum): | Value | Meaning | |---|---| | `AddedToList` | User was added to a list | | `TodoAssigned` | A todo was assigned to the user | | `TodoDueToday` | An assigned todo is due today | | `TodoOverdue` | An assigned todo is past its due date | ### 2.2 EF Core entity ```csharp // CqsTodo/Entities/NotificationEntity.cs using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CqsTodo.Entities; public class NotificationEntity : IEntity<NotificationEntity> { public NotificationId Id { get; set; } = NotificationId.From(0); public UserId RecipientId { get; set; } public UserEntity Recipient { get; set; } = null!; public TodoListId TodoListId { get; set; } public TodoListEntity TodoList { get; set; } = null!; public TodoNr? TodoNr { get; set; } // null for list-level kinds public NotificationKind Kind { get; set; } public string Body { get; set; } = string.Empty; public bool IsRead { get; set; } public DateTimeOffset CreatedAt { get; set; } public void Configure(EntityTypeBuilder<NotificationEntity> builder) { builder.HasKey(x => x.Id); builder.Property(x => x.Id).HasIdentityVogenColumn(); builder.HasOne(x => x.Recipient) .WithMany() .HasForeignKey(x => x.RecipientId) .OnDelete(DeleteBehavior.Cascade); builder.HasOne(x => x.TodoList) .WithMany() .HasForeignKey(x => x.TodoListId) .OnDelete(DeleteBehavior.Cascade); builder.Property(x => x.Kind).HasConversion<string>(); builder.HasIndex(x => new { x.RecipientId, x.CreatedAt }); // Idempotency index expressed in Fluent API via raw SQL annotation — see migration note } } ``` > **Note on the unique idempotency index:** EF Core does not support filtered unique indexes with an expression predicate (the `WHERE "TodoNr" IS NOT NULL` clause) through the Fluent API. The index is therefore created with a raw SQL statement inside the migration's `Up()` method rather than via `builder.HasIndex(...)`. ### 2.3 New enum ```csharp // CqsTodo/Entities/NotificationKind.cs namespace CqsTodo.Entities; public enum NotificationKind { AddedToList, TodoAssigned, TodoDueToday, TodoOverdue, } ``` ### 2.4 New Vogen value object ```csharp // Common/Types/NotificationId.cs using Vogen; namespace Common.Types; [ValueObject<int>] public partial struct NotificationId : IValueObject<NotificationId, int> { public static Validation Validate(int value) => value >= 0 ? Validation.Ok : Validation.Invalid("Must be 0 or positive value."); } ``` `NotificationId` must also be registered in `CqsTodo/DbContext/VogenEfCoreConverters.cs`: ```csharp [EfCoreConverter<NotificationId>] ``` ### 2.5 Migration name `AddNotificationEntity` --- ## 3. DTO Changes ### 3.1 New `NotificationDto` in `Common/Dtos/` ```csharp // Common/Dtos/NotificationDto.cs using Common.Types; using CqsTodo.Entities; namespace Common.Dtos; public record NotificationDto( NotificationId Id, NotificationKind Kind, string Body, TodoListId TodoListId, TodoListTitle TodoListTitle, TodoNr? TodoNr, bool IsRead, DateTimeOffset CreatedAt); ``` The `TodoListTitle` is denormalised into the DTO so the frontend can display the list name without a second round-trip. The `TodoNr` is included so the frontend can deep-link to the specific item when present. --- ## 4. Commands ### 4.1 `MarkNotificationReadCommand` ```csharp // CqsTodo/Features/Notifications/MarkNotificationReadCommandHandler.cs public record MarkNotificationReadCommand(NotificationId NotificationId) : IRequest<Unit>; ``` **Handler logic:** 1. Load the `NotificationEntity` by `NotificationId`. 2. If not found, throw `EntityNotFoundException`. 3. If `entity.RecipientId != currentUserId` throw `UnauthorizedAccessException` ("Not your notification"). 4. `ExecuteUpdateAsync` to set `IsRead = true`. 5. Return `Unit.Default`. No WS publish needed — the badge update is a client-side optimistic decrement on click (the notification list is re-fetched or the local Zustand store is patched). **Authorization:** ```csharp public static void ConfigureDi(IHandlerRegistrationConfig<MarkNotificationReadCommand> config) => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery()); ``` The ownership guard (step 3) is handled in the handler body, not as a separate `AuthorizeIsCurrentUserAuthenticated` decorator, because the authorization depends on loading the entity first. ### 4.2 `MarkAllNotificationsReadCommand` ```csharp // CqsTodo/Features/Notifications/MarkAllNotificationsReadCommandHandler.cs public record MarkAllNotificationsReadCommand : IRequest<Unit>; ``` **Handler logic:** 1. Get `currentUserId` from `GetCurrentUserIdQuery`. 2. `ExecuteUpdateAsync` all `NotificationEntity` rows where `RecipientId == currentUserId && IsRead == false`, setting `IsRead = true`. 3. Return `Unit.Default`. **Authorization:** ```csharp public static void ConfigureDi(IHandlerRegistrationConfig<MarkAllNotificationsReadCommand> config) => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery()); ``` ### 4.3 Internal `CreateNotificationCommand` (not exposed as an API endpoint) This is an internal command used by the notification-creating handlers and the background job. It is **not** registered via `MapRequests` (it is not `public`) and has no HTTP endpoint. ```csharp // CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs internal record CreateNotificationCommand( UserId RecipientId, TodoListId TodoListId, TodoNr? TodoNr, NotificationKind Kind, string Body) : IRequest<NotificationDto>; ``` **Handler logic:** 1. Insert a new `NotificationEntity` using the supplied parameters and `CreatedAt = DateTimeOffset.UtcNow`. 2. Re-read via a fresh `DbContext` (per established pattern) to obtain the generated `Id` and joined `TodoListTitle`. 3. Publish `notificationSubject.OnNext(new Change<NotificationId, NotificationDto>(ChangeReason.Add, dto.Id, dto))`. 4. Return the `NotificationDto`. Because `CreateNotificationCommand` is `internal`, `MapRequests` skips it (it only maps `public` `IRequest` types). **No `ConfigureDi`**: This handler is registered directly with `[SetupHandler]` and does not use `IHaveDiConfig` because it is not externally callable; authorization is enforced by the calling handlers. **Injection into calling handlers:** ```csharp IHandler<CreateNotificationCommand, NotificationDto> createNotificationHandler ``` --- ## 5. Queries ### 5.1 `GetNotificationsForCurrentUserQuery` ```csharp // CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs public record GetNotificationsForCurrentUserQuery( bool UnreadOnly = false, int Limit = 50) : IRequest<IReadOnlyCollection<NotificationDto>>; ``` **Handler logic:** 1. Get `currentUserId` from `GetCurrentUserIdQuery`. 2. Query `NotificationEntity` where `RecipientId == currentUserId`, ordered by `CreatedAt DESC`, limited to `Limit` rows. 3. If `UnreadOnly == true`, add `.Where(x => !x.IsRead)`. 4. Join `TodoListEntity` to get `Title` for the DTO. 5. Project to `NotificationDto` (inline select, no Mapperly needed for this simple projection). ```csharp return await dbContext.Set<NotificationEntity>() .Where(x => x.RecipientId == currentUserId) .Where(x => !request.UnreadOnly || !x.IsRead) .OrderByDescending(x => x.CreatedAt) .Take(request.Limit) .Select(x => new NotificationDto( x.Id, x.Kind, x.Body, x.TodoListId, x.TodoList.Title, x.TodoNr, x.IsRead, x.CreatedAt)) .ToArrayAsync(cancellationToken); ``` **Return type:** `IReadOnlyCollection<NotificationDto>` **Authorization:** ```csharp public static void ConfigureDi(IHandlerRegistrationConfig<GetNotificationsForCurrentUserQuery> config) => config.WithAuthorization(_ => new AuthorizeIsCurrentUserAuthenticatedQuery()); ``` **Note on `Limit`:** The frontend uses a fixed default of 50. The `Limit` parameter is validated to a maximum of 200 on the handler side to prevent DoS via large page fetches. Pagination is out of scope for this story. ### 5.2 `GetUnreadNotificationCountForCurrentUserQuery` ```csharp public record GetUnreadNotificationCountForCurrentUserQuery : IRequest<int>; ``` **Handler logic:** `COUNT(*)` where `RecipientId == currentUserId && !IsRead`. Used to populate the badge count on initial page load and after reconnect. **Authorization:** `AuthorizeIsCurrentUserAuthenticatedQuery`. --- ## 6. Real-Time Delivery ### 6.1 Chosen approach A new **user-scoped WebSocket publisher** (`UserScopedNotificationPublisher`) is added at `/api/changes/NotificationDto`. This follows exactly the same pattern as `UserScopedTodoChangePublisher` and `UserScopedTodoListChangePublisher`. The subject is `ISubject<Change<NotificationId, NotificationDto>>`. Notifications are inherently user-scoped (each notification has a single recipient), so a global broadcast subject filtered by `RecipientId` is all that is needed. No membership set tracking is required for this subject — notifications are not filtered by list access. ### 6.2 Subject registration in `CqsTodo/Setup.cs` ```csharp .AddSingleton<ISubject<Change<NotificationId, NotificationDto>>>( new Subject<Change<NotificationId, NotificationDto>>()) .AddSingleton<IObservable<Change<NotificationId, NotificationDto>>>(p => p.GetRequiredService<ISubject<Change<NotificationId, NotificationDto>>>().AsObservable()) ``` ### 6.3 `UserScopedNotificationPublisher` ```csharp // CqsTodo.WebApi/WebSocket/UserScopedNotificationPublisher.cs public partial class UserScopedNotificationPublisher( IObservable<Change<NotificationId, NotificationDto>> notificationChanges, IHandler<GetCurrentUserIdQuery, UserId?> getUserIdHandler, ILogger<UserScopedNotificationPublisher> logger) { public async Task Subscribe(HttpContext context) { // ... standard WS setup (see UserScopedTodoChangePublisher for exact pattern) ... var userId = await getUserIdHandler.Handle(...); if (userId is null) { context.Response.StatusCode = 401; return; } using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); var sendSemaphore = new SemaphoreSlim(1, 1); var notifSub = notificationChanges .Where(change => change.Data?.RecipientId == userId.Value || change.Id == /* check by recipient stored in a local lookup */ ...) ... } } ``` > **Recipient filter detail:** `Change<NotificationId, NotificationDto>` carries the full `NotificationDto` as `Data`. The filter is simply `.Where(change => change.Data!.RecipientId == userId.Value)`. Because `ChangeReason.Add` always has non-null `Data`, and notification deletes are not broadcast, `Data` can be assumed non-null. However, since `NotificationDto` contains `RecipientId` already, the publisher checks `change.Data?.RecipientId == userId.Value` to route the event only to the intended recipient's WebSocket connection. ### 6.4 Endpoint registration in `Program.cs` ```csharp app.Map( "/api/changes/NotificationDto", async context => await context.RequestServices.GetRequiredService<UserScopedNotificationPublisher>() .Subscribe(context)); ``` ```csharp // In ConfigureServices .AddTransient<UserScopedNotificationPublisher>() ``` ### 6.5 Why a new WS endpoint rather than piggybacking The existing `/api/changes/TodoDto` and `/api/changes/TodoListDto` channels are list-scoped; they carry `Change<TodoId, TodoDto>` and `Change<TodoListId, TodoListBroadcastDto>` respectively. Squeezing notification events onto one of those channels would require a discriminated union or an envelope type — a cross-cutting structural change. A new endpoint is cheaper, keeps each channel strongly typed, and the WebSocket handshake cost is negligible for a logged-in user who holds two or three connections already. --- ## 7. Background Job — Due-Today and Overdue Notifications ### 7.1 Scope The background job handles only `TodoDueToday` and `TodoOverdue` notifications. `AddedToList` and `TodoAssigned` notifications are always created synchronously inside their respective command handlers (§4.3). ### 7.2 Implementation approach Use `PeriodicTimer` inside a `BackgroundService` (ASP.NET Core `IHostedService` subclass). No external scheduler library (Hangfire, Quartz) is needed; `PeriodicTimer` was introduced in .NET 6 specifically for this pattern and avoids thread pool waste. ```csharp // CqsTodo/BackgroundJobs/DueNotificationJob.cs internal sealed class DueNotificationJob( IServiceScopeFactory scopeFactory, ILogger<DueNotificationJob> logger) : BackgroundService { // Run once per day, shortly after midnight UTC private static readonly TimeSpan Period = TimeSpan.FromHours(24); // Initial delay: time until next 00:05 UTC // Computed dynamically in ExecuteAsync protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var initialDelay = ComputeDelayToNextRunUtc(); await Task.Delay(initialDelay, stoppingToken); using var timer = new PeriodicTimer(Period); do { try { await RunAsync(stoppingToken); } catch (Exception ex) { logger.LogError(ex, "DueNotificationJob failed"); // Continue; next tick will retry } } while (await timer.WaitForNextTickAsync(stoppingToken)); } private static TimeSpan ComputeDelayToNextRunUtc() { var now = DateTimeOffset.UtcNow; var nextRun = now.Date.AddDays(1).AddMinutes(5); // 00:05 UTC next day return nextRun - now; } private async Task RunAsync(CancellationToken cancellationToken) { await using var scope = scopeFactory.CreateAsyncScope(); var handler = scope.ServiceProvider .GetRequiredService<IHandler<CreateDueNotificationsCommand, Unit>>(); await handler.Handle(new CreateDueNotificationsCommand(), cancellationToken); } } ``` **Registration in `Setup.cs`** (or in `Program.cs` via `builder.Services`): ```csharp services.AddHostedService<DueNotificationJob>(); ``` ### 7.3 `CreateDueNotificationsCommand` (internal, not HTTP-exposed) ```csharp // CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs internal record CreateDueNotificationsCommand : IRequest<Unit>; ``` **Handler logic:** ``` today = DateOnly.FromDateTime(DateTime.UtcNow) dueTodayTodos = TodoEntity WHERE DueDate == today AND AssigneeId IS NOT NULL AND DoneDate IS NULL overdueTodos = TodoEntity WHERE DueDate < today AND AssigneeId IS NOT NULL AND DoneDate IS NULL For each todo in dueTodayTodos: INSERT INTO NotificationEntity (RecipientId, TodoListId, TodoNr, Kind, Body, IsRead, CreatedAt) VALUES (AssigneeId, TodoListId, Nr, 'TodoDueToday', '<title> is due today', false, NOW()) ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey" DO NOTHING For each todo in overdueTodos: INSERT INTO NotificationEntity (RecipientId, TodoListId, TodoNr, Kind, Body, IsRead, CreatedAt) VALUES (AssigneeId, TodoListId, Nr, 'TodoOverdue', '<title> is overdue', false, NOW()) ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey" DO NOTHING For each successfully inserted notification: publish Change<NotificationId, NotificationDto>(ChangeReason.Add, ...) on notificationSubject ``` Because the `UX_NotificationEntity_IdempotencyKey` unique index includes `DATE(CreatedAt AT TIME ZONE 'UTC')`, re-running the job on the same calendar day (e.g., after a crash and restart) produces `ON CONFLICT ... DO NOTHING` for already-created rows. The job therefore never double-notifies. **Implementation note on upsert:** EF Core 10 supports `ExecuteInsertAsync` with conflict resolution via `UseStrategy(InsertConflictResolution.DoNothing)` or a raw SQL interpolated string. The raw SQL approach is preferred here because the `InsertAsync` EF Core API does not yet surface per-column conflict targets: ```csharp await dbContext.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO "NotificationEntity" ("RecipientId","TodoListId","TodoNr","Kind","Body","IsRead","CreatedAt") VALUES ({assigneeId.Value},{listId.Value},{nr.Value},'TodoDueToday',{body},false,now()) ON CONFLICT ON CONSTRAINT "UX_NotificationEntity_IdempotencyKey" DO NOTHING """, cancellationToken); ``` The inserted IDs are then fetched back (query the rows just inserted with a short time window) and published. > **Alternative considered:** Insert via `dbContext.Add(entity)` inside a try/catch for `UniqueConstraintException`. Rejected because: (a) it generates one round-trip per todo, (b) exception-based flow control is slower and noisy in logs. The bulk `ON CONFLICT DO NOTHING` approach is cleaner. ### 7.4 Purge strategy (see also §8) The `CreateDueNotificationsCommand` handler also runs the 30-day purge in the same transaction/scope: ```csharp var cutoff = DateTimeOffset.UtcNow.AddDays(-30); await dbContext.Set<NotificationEntity>() .Where(x => x.CreatedAt < cutoff) .ExecuteDeleteAsync(cancellationToken); ``` Bundling purge with the notification job keeps the codebase simple — no separate hosted service needed. --- ## 8. 30-Day Purge Strategy As noted in §7.4, purge is executed once per day inside `CreateDueNotificationsCommand`. The `ExecuteDeleteAsync` call targets `CreatedAt < (UtcNow - 30 days)` and uses a single bulk-delete SQL statement, which is efficient even for large tables (the index on `RecipientId, CreatedAt` covers the range scan). If the application is restarted or the job fails, rows may accumulate beyond 30 days until the next successful run. This is acceptable for MVP (a cosmetic worst-case of one extra day of stale rows). No separate purge command, endpoint, or hosted service is added. --- ## 9. Trigger Points — Handler Modifications ### 9.1 `AcceptListInvitationCommandHandler` — "Added to list" notification After the membership is saved and the list DTO is fetched, inject and call: ```csharp var listTitle = dto.Title.Value; // e.g., "Groceries" // The inviter's display name is not easily available here; use "You were added" wording var body = $"You were added to '{listTitle}'"; await createNotificationHandler.Handle( new CreateNotificationCommand( RecipientId: userId!.Value, TodoListId: invitation.TodoListId, TodoNr: null, Kind: NotificationKind.AddedToList, Body: body), cancellationToken); ``` > **Inviter name:** The `TodoListInvitationEntity` does not record who created the invitation. Retrieving the inviter would require joining through the `TodoListToUserEntity` owner record. For MVP, the notification body is "You were added to 'X'" without naming the inviter. If the inviter's name is needed in the future, `TodoListInvitationEntity` can gain a `CreatedByUserId` column. This is left as an open question (see §11). ### 9.2 `AssignTodoCommandHandler` — "Todo assigned" notification After publishing the todo change event, and only when `request.AssigneeId` is non-null (a clear-assignment produces no notification): ```csharp if (request.AssigneeId is not null) { var body = $"'{dto.Title.Value}' was assigned to you"; await createNotificationHandler.Handle( new CreateNotificationCommand( RecipientId: request.AssigneeId.Value, TodoListId: request.TodoListId, TodoNr: request.Nr, Kind: NotificationKind.TodoAssigned, Body: body), cancellationToken); } ``` **Self-assignment edge case:** If the current user assigns a todo to themselves, a notification is still created. The story AC does not exclude self-assignment notifications; this is the simplest behaviour and consistent with the other notification types. --- ## 10. Authorization Summary | Operation | Handler | Required authorization | |---|---|---| | Get notifications (own) | `GetNotificationsForCurrentUserQueryHandler` | Authenticated (`AuthorizeIsCurrentUserAuthenticatedQuery`) | | Get unread count | `GetUnreadNotificationCountForCurrentUserQueryHandler` | Authenticated | | Mark one notification read | `MarkNotificationReadCommandHandler` | Authenticated + ownership check in handler body | | Mark all notifications read | `MarkAllNotificationsReadCommandHandler` | Authenticated | | Create notification (internal) | `CreateNotificationCommandHandler` | Not exposed via HTTP; no decorator needed | | Create due notifications (job) | `CreateDueNotificationsCommandHandler` | Not exposed via HTTP; runs in `IHostedService` scope | | WS subscribe `/api/changes/NotificationDto` | `UserScopedNotificationPublisher` | 401 if `GetCurrentUserIdQuery` returns null (same pattern as other WS publishers) | **Security notes for pre-review:** 1. **Ownership check for `MarkNotificationReadCommand`:** A user must not be able to mark another user's notification as read. The handler must load the entity and compare `RecipientId == currentUserId` before updating. This check MUST NOT be skipped even though only the notification owner can know the `NotificationId` (IDs are sequential integers, trivially enumerable). 2. **`GetNotificationsForCurrentUserQuery` never leaks cross-user data:** The WHERE clause always scopes by `currentUserId`; there is no `NotificationId`-based lookup that could return another user's row. 3. **WS filter:** `UserScopedNotificationPublisher` filters on `change.Data!.RecipientId == userId.Value`. `Data` is always non-null for `ChangeReason.Add` events and notifications are only ever added (never updated or deleted via the WS subject). This is safe. 4. **Background job runs without HTTP context:** `CreateDueNotificationsCommand` must NOT use `GetCurrentUserIdQuery` — it operates on behalf of all users. The job injects `RecipientId` directly from the scanned todo rows. 5. **Notification body is rendered server-side:** The `Body` string is constructed in C# from data already in the DB (list title, todo title). It is not constructed from user-supplied free text except through those already-validated fields. No injection risk beyond what those fields already carry. --- ## 11. Files That Will Change / New Files ### Backend — new files | File | Purpose | |---|---| | `Common/Types/NotificationId.cs` | Vogen VO for notification PK | | `Common/Dtos/NotificationDto.cs` | DTO returned to frontend and broadcast over WS | | `CqsTodo/Entities/NotificationEntity.cs` | EF Core entity + `IEntity<T>` configuration | | `CqsTodo/Entities/NotificationKind.cs` | Enum for notification type | | `CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs` | Internal command: insert + publish | | `CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs` | Internal command: bulk insert due/overdue + purge | | `CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs` | Query: fetch notification list | | `CqsTodo/Features/Notifications/GetUnreadNotificationCountForCurrentUserQueryHandler.cs` | Query: badge count | | `CqsTodo/Features/Notifications/MarkNotificationReadCommandHandler.cs` | Command: mark one read | | `CqsTodo/Features/Notifications/MarkAllNotificationsReadCommandHandler.cs` | Command: mark all read | | `CqsTodo/BackgroundJobs/DueNotificationJob.cs` | `BackgroundService` hosting the periodic timer | | `CqsTodo.WebApi/WebSocket/UserScopedNotificationPublisher.cs` | WS publisher filtered by `RecipientId` | | `CqsTodo/Migrations/<timestamp>_AddNotificationEntity.cs` | EF Core migration | ### Backend — files that change | File | Change | |---|---| | `CqsTodo/DbContext/VogenEfCoreConverters.cs` | Add `[EfCoreConverter<NotificationId>]` | | `CqsTodo/Setup.cs` | Register `ISubject<Change<NotificationId,NotificationDto>>` and its observable | | `CqsTodo.WebApi/Program.cs` | Register `UserScopedNotificationPublisher` as transient; map `/api/changes/NotificationDto`; register `DueNotificationJob` via `AddHostedService` | | `CqsTodo/Features/TodoLists/AcceptListInvitationCommandHandler.cs` | Inject `IHandler<CreateNotificationCommand, NotificationDto>`; create `AddedToList` notification after membership save | | `CqsTodo/Features/Todos/AssignTodoCommandHandler.cs` | Inject `IHandler<CreateNotificationCommand, NotificationDto>`; create `TodoAssigned` notification after todo update | ### Test files — new | File | Tests | |---|---| | `CqsTodo.Tests/Features/Notifications/CreateNotificationCommandHandlerTests.cs` | Inserts row, publishes WS event, returns DTO with correct fields | | `CqsTodo.Tests/Features/Notifications/GetNotificationsForCurrentUserQueryHandlerTests.cs` | Returns own notifications; pagination; `UnreadOnly` filter; no cross-user leakage | | `CqsTodo.Tests/Features/Notifications/GetUnreadNotificationCountForCurrentUserQueryHandlerTests.cs` | Zero count when no unread; count increments; count excludes other users | | `CqsTodo.Tests/Features/Notifications/MarkNotificationReadCommandHandlerTests.cs` | Marks own notification read; rejects marking another user's notification (throws `UnauthorizedAccessException`) | | `CqsTodo.Tests/Features/Notifications/MarkAllNotificationsReadCommandHandlerTests.cs` | All own unread become read; other users' notifications untouched | | `CqsTodo.Tests/Features/Notifications/CreateDueNotificationsCommandHandlerTests.cs` | Due-today job creates `TodoDueToday` rows; overdue job creates `TodoOverdue` rows; idempotent on re-run same day; done todos skipped; unassigned todos skipped; purges rows older than 30 days | | `CqsTodo.Tests/Features/TodoLists/AcceptListInvitationCommandHandlerTests.cs` | Extend: notification created for joining user | | `CqsTodo.Tests/Features/Todos/AssignTodoCommandHandlerTests.cs` | Extend: notification created for assignee; no notification on clear; no notification on self-assignment (or assert one is created, depending on decision in §11 Q3) | ### Frontend — out of scope for this doc; components needed - `NotificationBell` — header icon + unread badge; opens `NotificationPanel` - `NotificationPanel` — dropdown/drawer listing `NotificationDto[]`; "Mark all as read" button - `NotificationItem` — renders body, list name, relative timestamp; navigates on click - Zustand store slice: `notifications: NotificationDto[]`, `unreadCount: number`; WebSocket subscription on `/api/changes/NotificationDto`; actions: `markRead(id)`, `markAllRead()`, `fetchInitial()` - API functions: `getNotificationsForCurrentUser()`, `getUnreadNotificationCount()`, `markNotificationRead(id)`, `markAllNotificationsRead()` --- ## 12. Open Questions for Security Agent **PO decisions (resolved before security pre-review):** - **Q1 — Inviter name:** Out of scope for this story. The wording "You were added to 'X'" is acceptable for MVP. Adding the inviter's name requires a new column on `TodoListInvitationEntity` and is a separate scope item. - **Q2 — Self-assignment notification:** Suppress it. If the current user assigns a todo to themselves, no notification is created (`request.AssigneeId == currentUserId` → skip). You already know you assigned it to yourself. - **Q3 — Re-assignment stale notification:** Accepted as-is. Alice's old `TodoAssigned` notification stays in her list after reassignment — this is fine for MVP. Alice was a member and saw the todo title; no new information is leaked. - **Q4 — Unassignment notification:** Out of scope for this story. **For Security Agent review:** 5. **Sequential `NotificationId` enumeration:** Should `MarkNotificationReadCommand` return 404 (not found) or 403 (forbidden) when a user probes another user's notification? Recommend 404 to avoid confirming existence of other users' notifications. 6. **Rate limiting:** Is the existing API layer rate-limiting sufficient for `GetNotificationsForCurrentUserQuery` and `GetUnreadNotificationCountForCurrentUserQuery`? These are cheap but could be called in a tight loop. 7. **Multi-instance duplicate WS publish:** In a multi-replica deployment, `DueNotificationJob` runs in every instance. The `ON CONFLICT DO NOTHING` prevents DB duplicates, but the WS publish would fire in every instance for the same notification. The handler must check the affected-row count from `ExecuteSqlInterpolatedAsync` — if 0 rows inserted (conflict), skip the publish. Confirm this is the correct mitigation.
lena commented 2026-08-18 13:12:56 +02:00 (Migrated from git.butzei.de)

handoff (26_in_app_notifications_handoff.md)

Handoff: Story #26 — In-App Notification Center

Branch: feature/in-app-notifications
Status: Ready for human review
Date: 2026-07-10


What was built

A full in-app notification center: bell icon with unread badge, real-time delivery
over WebSocket, persistent storage in PostgreSQL, and a daily background job for
due-today / overdue notifications.

Commits

Commit Description
426d9d7 Backend: NotificationEntity, 6 handlers, background job, WS publisher
cdf5757 Frontend: NotificationBell, NotificationPanel, NotificationItem
115b149 Tests: 33 new handler tests + 3 production bug fixes found during QA
53b30e3 Security final review approved; Limit lower-bound guard

Acceptance criteria status

  • Bell icon in application header for every logged-in user
  • Badge showing unread count (hidden when 0)
  • Clicking bell opens notification panel (newest first)
  • "You were added to a list" notification on AcceptListInvitation
  • "Todo assigned to you" notification on AssignTodo (suppressed for self-assign)
  • "Due today" / "Overdue" notifications via daily background job (00:05 UTC)
  • Each notification shows body, list name, relative time
  • Clicking navigates to the relevant list (+ marks as read)
  • "Mark all as read" clears the badge
  • Notifications persist across sessions (PostgreSQL)
  • 30-day auto-purge (co-located with the daily job)

Test coverage

  • Backend: 174 tests pass (305 total across all projects)
  • Frontend: 170 tests pass (25 test files)
  • 33 new backend tests; 11 new frontend tests

Security

All required and should-fix items from the pre-review are addressed:

  1. IDOR masking: MarkNotificationRead returns 404 for both not-found and wrong-user
  2. RecipientId not on wire: wrapper observable (UserId, Change<...>) used for routing
  3. TodoListTitle capped: 255 chars max; Body column HasMaxLength(2048)
  4. Limit enforced: 1–200 range throws ArgumentException in handler body
  5. Multi-replica WS dedup: publish only when INSERT affected rows == 1

Final security verdict: Approved.


Known limitations / out of scope

  • Email notifications (#28)
  • Browser push notifications (#29)
  • User-configurable notification preferences
  • Non-sequential notification IDs (deferred per security review Finding 2)
  • Inviter name in "added to list" body (deferred per PO decision)
**handoff** (`26_in_app_notifications_handoff.md`) # Handoff: Story `#26` — In-App Notification Center **Branch:** `feature/in-app-notifications` **Status:** Ready for human review **Date:** 2026-07-10 --- ## What was built A full in-app notification center: bell icon with unread badge, real-time delivery over WebSocket, persistent storage in PostgreSQL, and a daily background job for due-today / overdue notifications. ### Commits | Commit | Description | |--------|-------------| | `426d9d7` | Backend: NotificationEntity, 6 handlers, background job, WS publisher | | `cdf5757` | Frontend: NotificationBell, NotificationPanel, NotificationItem | | `115b149` | Tests: 33 new handler tests + 3 production bug fixes found during QA | | `53b30e3` | Security final review approved; Limit lower-bound guard | --- ## Acceptance criteria status - [x] Bell icon in application header for every logged-in user - [x] Badge showing unread count (hidden when 0) - [x] Clicking bell opens notification panel (newest first) - [x] "You were added to a list" notification on `AcceptListInvitation` - [x] "Todo assigned to you" notification on `AssignTodo` (suppressed for self-assign) - [x] "Due today" / "Overdue" notifications via daily background job (00:05 UTC) - [x] Each notification shows body, list name, relative time - [x] Clicking navigates to the relevant list (+ marks as read) - [x] "Mark all as read" clears the badge - [x] Notifications persist across sessions (PostgreSQL) - [x] 30-day auto-purge (co-located with the daily job) --- ## Test coverage - Backend: 174 tests pass (305 total across all projects) - Frontend: 170 tests pass (25 test files) - 33 new backend tests; 11 new frontend tests --- ## Security All required and should-fix items from the pre-review are addressed: 1. **IDOR masking**: `MarkNotificationRead` returns 404 for both not-found and wrong-user 2. **RecipientId not on wire**: wrapper observable `(UserId, Change<...>)` used for routing 3. **TodoListTitle capped**: 255 chars max; `Body` column `HasMaxLength(2048)` 4. **Limit enforced**: 1–200 range throws `ArgumentException` in handler body 5. **Multi-replica WS dedup**: publish only when INSERT affected rows == 1 Final security verdict: **Approved**. --- ## Known limitations / out of scope - Email notifications (`#28`) - Browser push notifications (`#29`) - User-configurable notification preferences - Non-sequential notification IDs (deferred per security review Finding 2) - Inviter name in "added to list" body (deferred per PO decision)
lena commented 2026-08-18 13:12:57 +02:00 (Migrated from git.butzei.de)

security_final (26_in_app_notifications_security_final.md)

Security Final Review: Story #26 — In-App Notification Center

Reviewer: Security Agent
Date: 2026-07-10
Branch: feature/in-app-notifications
Pre-review document: 26_in_app_notifications_security.md
Status: Approved


Summary

All five pre-review findings (two required, three should-fix) have been correctly and completely implemented. No new high- or medium-severity issues were found during the final review. Two minor observations are noted below for completeness; neither blocks merge.


Pre-Review Findings — Verdict

Finding 1 (Required, High) — IDOR oracle in MarkNotificationReadCommand

Verdict: Resolved

MarkNotificationReadCommandHandler.cs throws EntityNotFound.Create<NotificationEntity>(...) in both the "does not exist" and "belongs to another user" cases (lines 27–34). The comment in the handler explicitly documents the deliberate security decision:

"Security Finding 1 (required fix): throw EntityNotFoundException rather than UnauthorizedAccessException when the notification belongs to a different user. This returns HTTP 404 in both cases, eliminating the 403-vs-404 enumeration oracle."

The test Throws_EntityNotFoundException_not_UnauthorizedAccessException_when_notification_belongs_to_different_user in MarkNotificationReadCommandHandlerTests.cs asserts EntityNotFoundException (not UnauthorizedAccessException), explicitly comments the HTTP 404 / enumeration rationale, and additionally verifies the notification remains unread after the rejected probe. Finding fully addressed.


Finding 2 (Info) — Sequential NotificationId exposure

Verdict: Resolved (deferred by design)

The 404-masking fix from Finding 1 is in place, which is the required MVP control. Switching to a non-sequential ID type remains deferred, consistent with the pre-review recommendation. No action required for this story.


Finding 3 (Required, Medium) — RecipientId in NotificationDto leaking over WebSocket

Verdict: Resolved

The preferred wrapper-observable approach (option 1 from the pre-review) was implemented:

  • NotificationDto (Common/Dtos/NotificationDto.cs) contains no RecipientId field. The comment documents this as intentional.
  • The subject registered in Setup.cs is ISubject<(UserId RecipientId, Change<NotificationId, NotificationDto>)> — the recipient lives in the tuple wrapper, never in the serialised DTO.
  • CreateNotificationCommandHandler publishes (request.RecipientId, new Change<...>(...)) on the wrapper subject.
  • CreateDueNotificationsCommandHandler does the same in InsertAndPublishAsync.
  • UserScopedNotificationPublisher injects IObservable<(UserId RecipientId, Change<NotificationId, NotificationDto> Change)>, filters .Where(x => x.RecipientId == userId.Value), then extracts .Select(x => x.Change) before serialising. The JSON payload sent to the browser is Change<NotificationId, NotificationDto> only — no RecipientId in the wire format.

Finding fully addressed.


Finding 4 (Should-fix, Medium) — TodoListTitle length cap and Body HasMaxLength

Verdict: Resolved

TodoListTitle.Validate() (Common/Types/TodoListTitle.cs) now rejects values longer than 255 characters:

: value.Length > 255 ? Validation.Invalid("Must not exceed 255 characters.")

NotificationEntity.Configure() sets builder.Property(x => x.Body).HasMaxLength(2048). The migration confirms the column is character varying(2048).

Worst-case body lengths (all safely under 2048):

Template Max source length Max body length
"You were added to '<title>'" TodoListTitle = 255 275 chars
"'<title>' was assigned to you" TodoTitle = 1,024 1,046 chars
"'<title>' is due today" TodoTitle = 1,024 1,039 chars
"'<title>' is overdue" TodoTitle = 1,024 1,037 chars

The 2,048-char column cap is generous enough to accommodate all templates with full-length source fields, with headroom to spare. Finding fully addressed.


Finding 5 (Info) — WebSocket authentication ordering

Verdict: Resolved (pattern correctly followed)

UserScopedNotificationPublisher.Subscribe() follows the identical guard sequence as the reference implementation (UserScopedTodoListChangePublisher):

  1. IsWebSocketRequest → 400 if false (line 33–37).
  2. getUserIdHandler.Handle(...) → 401 if null (lines 40–45).
  3. AcceptWebSocketAsync() only after both checks pass (line 47).

The comment on line 39 explicitly references "Security Finding 5 pattern". Finding confirmed correct.


Finding 6 (Should-fix, Medium) — Limit parameter not server-enforced

Verdict: Resolved

GetNotificationsForCurrentUserQueryHandler guards at the top of Handle:

if (request.Limit > 200)
    throw new ArgumentException("Limit cannot exceed 200.", nameof(request));

A dedicated test Throws_ArgumentException_when_Limit_exceeds_200 asserts that Limit: 201 throws ArgumentException. Finding fully addressed.


Finding 7 (Should-fix, Info) — Multi-replica duplicate WS publish

Verdict: Resolved

CreateDueNotificationsCommandHandler.InsertAndPublishAsync() uses individual INSERT ... ON CONFLICT ... DO NOTHING statements and checks affected != 1 before publishing (lines 102–113). Only the replica that inserted the row (return value = 1) calls notificationSubject.OnNext(...). Replicas that lose the race (return value = 0) return early.

The comment in the handler explicitly explains the multi-replica rationale. The post-insert read uses a time-window query on CreatedAt >= todayOffset with an OrderByDescending(...).FirstOrDefaultAsync(...) to fetch the DTO for publishing — this is safe because only the inserting replica reaches this code path (the affected != 1 guard precedes it).

One detail: the post-insert query filters by RecipientId, TodoListId, TodoNr, Kind, and CreatedAt >= todayOffset. In the unlikely event of clock skew where todayOffset slightly lags the inserted row's CreatedAt, FirstOrDefaultAsync returns null and the handler silently skips the WS publish (line 137–138). The row is already in the DB; the user will see it on next poll or reconnect. This is an acceptable edge case for a daily background job. Finding fully addressed.


New Findings

NF-1 (Low) — Limit parameter has no lower-bound guard

File: CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs

The handler guards Limit > 200 but does not guard Limit <= 0. Passing Limit = 0 returns an empty list (harmless — Take(0) is valid). Passing Limit = -1 would throw ArgumentOutOfRangeException from EF Core's Take translation in .NET 10.

This is not a security issue — the endpoint requires authentication, and the default is 50. However, the exception thrown for a negative Limit would be an unhandled ArgumentOutOfRangeException rather than the clean ArgumentException used for Limit > 200, which could produce a different HTTP status code depending on how ExceptionHandler.cs maps that exception type.

Recommendation: Add if (request.Limit <= 0) throw new ArgumentException("Limit must be positive.", nameof(request)); alongside the existing upper-bound check. Low priority — no security impact.


NF-2 (Info) — CreateDueNotificationsCommand and CreateNotificationCommand confirmed not HTTP-exposed

Files: CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs, CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs

Both commands are declared internal. EndpointRouteBuilderExtensions.MapRequests filters on x.IsPublic (line 26 of EndpointRouteBuilderExtensions.cs), which means both commands are invisible to the HTTP routing layer by design. Confirmed safe — no action required.


NF-3 (Info) — In-memory overdue filter is a performance consideration, not a security concern

File: CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs (lines 41–57)

The overdue query fetches all incomplete, assigned todos with a non-null DueDate and applies the < today filter in-memory due to a DateOnly/DateTime value-converter limitation. As noted in the code comment, this is acceptable for a once-daily background job. There is no security concern here; the data is read from trusted DB rows, not from user input. The comment should be retained in the code as documentation of the design decision.


Authorization Model — Final Assessment

Operation Auth Scope Verdict
GetNotificationsForCurrentUserQuery Required WHERE RecipientId == currentUserId Correct
GetUnreadNotificationCountForCurrentUserQuery Required COUNT WHERE RecipientId == currentUserId Correct
MarkNotificationReadCommand Required 404 for missing or other-user notification Correct
MarkAllNotificationsReadCommand Required ExecuteUpdate WHERE RecipientId == currentUserId Correct
CreateNotificationCommand N/A (internal) Caller supplies trusted RecipientId Correct
CreateDueNotificationsCommand N/A (job) RecipientId from DB rows (AssigneeId) Correct
WS /api/changes/NotificationDto 401 before upgrade Wrapper tuple filter, no RecipientId in DTO Correct
AcceptListInvitation notification Per-command auth Created only when !isAlreadyMember Correct
AssignTodo notification Per-command auth Created only when assigneeId != null && assigneeId != currentUserId Correct

Migration Verification

The migration 20260709214336_AddNotificationEntity.cs was reviewed:

  • Body column: character varying(2048) — matches HasMaxLength(2048).
  • UX_NotificationEntity_IdempotencyKey partial unique index created via raw SQL in Up() with WHERE "TodoNr" IS NOT NULL — matches the design requirement.
  • Down() drops the index explicitly before dropping the table — correct.
  • Both FK constraints use onDelete: ReferentialAction.Cascade — correct per design.

Overall Verdict

Approved.

All five pre-review findings (Findings 1, 3, 4, 6, 7) are correctly implemented. The two informational items (Findings 2, 5, 8, 9) were handled as designed. New Finding NF-1 (missing lower-bound Limit guard) is low priority with no security impact and does not block merge. The branch is ready for code review and merge.

**security_final** (`26_in_app_notifications_security_final.md`) # Security Final Review: Story `#26` — In-App Notification Center **Reviewer:** Security Agent **Date:** 2026-07-10 **Branch:** `feature/in-app-notifications` **Pre-review document:** `26_in_app_notifications_security.md` **Status:** Approved --- ## Summary All five pre-review findings (two required, three should-fix) have been correctly and completely implemented. No new high- or medium-severity issues were found during the final review. Two minor observations are noted below for completeness; neither blocks merge. --- ## Pre-Review Findings — Verdict ### Finding 1 (Required, High) — IDOR oracle in `MarkNotificationReadCommand` **Verdict: Resolved** `MarkNotificationReadCommandHandler.cs` throws `EntityNotFound.Create<NotificationEntity>(...)` in both the "does not exist" and "belongs to another user" cases (lines 27–34). The comment in the handler explicitly documents the deliberate security decision: > "Security Finding 1 (required fix): throw EntityNotFoundException rather than UnauthorizedAccessException when the notification belongs to a different user. This returns HTTP 404 in both cases, eliminating the 403-vs-404 enumeration oracle." The test `Throws_EntityNotFoundException_not_UnauthorizedAccessException_when_notification_belongs_to_different_user` in `MarkNotificationReadCommandHandlerTests.cs` asserts `EntityNotFoundException` (not `UnauthorizedAccessException`), explicitly comments the HTTP 404 / enumeration rationale, and additionally verifies the notification remains unread after the rejected probe. Finding fully addressed. --- ### Finding 2 (Info) — Sequential `NotificationId` exposure **Verdict: Resolved (deferred by design)** The 404-masking fix from Finding 1 is in place, which is the required MVP control. Switching to a non-sequential ID type remains deferred, consistent with the pre-review recommendation. No action required for this story. --- ### Finding 3 (Required, Medium) — `RecipientId` in `NotificationDto` leaking over WebSocket **Verdict: Resolved** The preferred wrapper-observable approach (option 1 from the pre-review) was implemented: - `NotificationDto` (`Common/Dtos/NotificationDto.cs`) contains no `RecipientId` field. The comment documents this as intentional. - The subject registered in `Setup.cs` is `ISubject<(UserId RecipientId, Change<NotificationId, NotificationDto>)>` — the recipient lives in the tuple wrapper, never in the serialised DTO. - `CreateNotificationCommandHandler` publishes `(request.RecipientId, new Change<...>(...))` on the wrapper subject. - `CreateDueNotificationsCommandHandler` does the same in `InsertAndPublishAsync`. - `UserScopedNotificationPublisher` injects `IObservable<(UserId RecipientId, Change<NotificationId, NotificationDto> Change)>`, filters `.Where(x => x.RecipientId == userId.Value)`, then extracts `.Select(x => x.Change)` before serialising. The JSON payload sent to the browser is `Change<NotificationId, NotificationDto>` only — no `RecipientId` in the wire format. Finding fully addressed. --- ### Finding 4 (Should-fix, Medium) — `TodoListTitle` length cap and `Body` `HasMaxLength` **Verdict: Resolved** `TodoListTitle.Validate()` (`Common/Types/TodoListTitle.cs`) now rejects values longer than 255 characters: ```csharp : value.Length > 255 ? Validation.Invalid("Must not exceed 255 characters.") ``` `NotificationEntity.Configure()` sets `builder.Property(x => x.Body).HasMaxLength(2048)`. The migration confirms the column is `character varying(2048)`. **Worst-case body lengths** (all safely under 2048): | Template | Max source length | Max body length | |---|---|---| | `"You were added to '<title>'"` | TodoListTitle = 255 | 275 chars | | `"'<title>' was assigned to you"` | TodoTitle = 1,024 | 1,046 chars | | `"'<title>' is due today"` | TodoTitle = 1,024 | 1,039 chars | | `"'<title>' is overdue"` | TodoTitle = 1,024 | 1,037 chars | The 2,048-char column cap is generous enough to accommodate all templates with full-length source fields, with headroom to spare. Finding fully addressed. --- ### Finding 5 (Info) — WebSocket authentication ordering **Verdict: Resolved (pattern correctly followed)** `UserScopedNotificationPublisher.Subscribe()` follows the identical guard sequence as the reference implementation (`UserScopedTodoListChangePublisher`): 1. `IsWebSocketRequest` → 400 if false (line 33–37). 2. `getUserIdHandler.Handle(...)` → 401 if null (lines 40–45). 3. `AcceptWebSocketAsync()` only after both checks pass (line 47). The comment on line 39 explicitly references "Security Finding 5 pattern". Finding confirmed correct. --- ### Finding 6 (Should-fix, Medium) — `Limit` parameter not server-enforced **Verdict: Resolved** `GetNotificationsForCurrentUserQueryHandler` guards at the top of `Handle`: ```csharp if (request.Limit > 200) throw new ArgumentException("Limit cannot exceed 200.", nameof(request)); ``` A dedicated test `Throws_ArgumentException_when_Limit_exceeds_200` asserts that `Limit: 201` throws `ArgumentException`. Finding fully addressed. --- ### Finding 7 (Should-fix, Info) — Multi-replica duplicate WS publish **Verdict: Resolved** `CreateDueNotificationsCommandHandler.InsertAndPublishAsync()` uses individual `INSERT ... ON CONFLICT ... DO NOTHING` statements and checks `affected != 1` before publishing (lines 102–113). Only the replica that inserted the row (return value = 1) calls `notificationSubject.OnNext(...)`. Replicas that lose the race (return value = 0) return early. The comment in the handler explicitly explains the multi-replica rationale. The post-insert read uses a time-window query on `CreatedAt >= todayOffset` with an `OrderByDescending(...).FirstOrDefaultAsync(...)` to fetch the DTO for publishing — this is safe because only the inserting replica reaches this code path (the `affected != 1` guard precedes it). One detail: the post-insert query filters by `RecipientId`, `TodoListId`, `TodoNr`, `Kind`, and `CreatedAt >= todayOffset`. In the unlikely event of clock skew where `todayOffset` slightly lags the inserted row's `CreatedAt`, `FirstOrDefaultAsync` returns null and the handler silently skips the WS publish (line 137–138). The row is already in the DB; the user will see it on next poll or reconnect. This is an acceptable edge case for a daily background job. Finding fully addressed. --- ## New Findings ### NF-1 (Low) — `Limit` parameter has no lower-bound guard **File:** `CqsTodo/Features/Notifications/GetNotificationsForCurrentUserQueryHandler.cs` The handler guards `Limit > 200` but does not guard `Limit <= 0`. Passing `Limit = 0` returns an empty list (harmless — `Take(0)` is valid). Passing `Limit = -1` would throw `ArgumentOutOfRangeException` from EF Core's `Take` translation in .NET 10. This is not a security issue — the endpoint requires authentication, and the default is 50. However, the exception thrown for a negative `Limit` would be an unhandled `ArgumentOutOfRangeException` rather than the clean `ArgumentException` used for `Limit > 200`, which could produce a different HTTP status code depending on how `ExceptionHandler.cs` maps that exception type. **Recommendation:** Add `if (request.Limit <= 0) throw new ArgumentException("Limit must be positive.", nameof(request));` alongside the existing upper-bound check. Low priority — no security impact. --- ### NF-2 (Info) — `CreateDueNotificationsCommand` and `CreateNotificationCommand` confirmed not HTTP-exposed **Files:** `CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs`, `CqsTodo/Features/Notifications/CreateNotificationCommandHandler.cs` Both commands are declared `internal`. `EndpointRouteBuilderExtensions.MapRequests` filters on `x.IsPublic` (line 26 of `EndpointRouteBuilderExtensions.cs`), which means both commands are invisible to the HTTP routing layer by design. Confirmed safe — no action required. --- ### NF-3 (Info) — In-memory overdue filter is a performance consideration, not a security concern **File:** `CqsTodo/Features/Notifications/CreateDueNotificationsCommandHandler.cs` (lines 41–57) The overdue query fetches all incomplete, assigned todos with a non-null `DueDate` and applies the `< today` filter in-memory due to a `DateOnly`/`DateTime` value-converter limitation. As noted in the code comment, this is acceptable for a once-daily background job. There is no security concern here; the data is read from trusted DB rows, not from user input. The comment should be retained in the code as documentation of the design decision. --- ## Authorization Model — Final Assessment | Operation | Auth | Scope | Verdict | |---|---|---|---| | `GetNotificationsForCurrentUserQuery` | Required | WHERE `RecipientId == currentUserId` | Correct | | `GetUnreadNotificationCountForCurrentUserQuery` | Required | COUNT WHERE `RecipientId == currentUserId` | Correct | | `MarkNotificationReadCommand` | Required | 404 for missing or other-user notification | Correct | | `MarkAllNotificationsReadCommand` | Required | ExecuteUpdate WHERE `RecipientId == currentUserId` | Correct | | `CreateNotificationCommand` | N/A (internal) | Caller supplies trusted `RecipientId` | Correct | | `CreateDueNotificationsCommand` | N/A (job) | `RecipientId` from DB rows (AssigneeId) | Correct | | WS `/api/changes/NotificationDto` | 401 before upgrade | Wrapper tuple filter, no `RecipientId` in DTO | Correct | | `AcceptListInvitation` notification | Per-command auth | Created only when `!isAlreadyMember` | Correct | | `AssignTodo` notification | Per-command auth | Created only when `assigneeId != null && assigneeId != currentUserId` | Correct | --- ## Migration Verification The migration `20260709214336_AddNotificationEntity.cs` was reviewed: - `Body` column: `character varying(2048)` — matches `HasMaxLength(2048)`. - `UX_NotificationEntity_IdempotencyKey` partial unique index created via raw SQL in `Up()` with `WHERE "TodoNr" IS NOT NULL` — matches the design requirement. - `Down()` drops the index explicitly before dropping the table — correct. - Both FK constraints use `onDelete: ReferentialAction.Cascade` — correct per design. --- ## Overall Verdict **Approved.** All five pre-review findings (Findings 1, 3, 4, 6, 7) are correctly implemented. The two informational items (Findings 2, 5, 8, 9) were handled as designed. New Finding NF-1 (missing lower-bound Limit guard) is low priority with no security impact and does not block merge. The branch is ready for code review and merge.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#26
No description provided.