-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add BotDM re-subscribe upsert with DisableNotification field #202
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
2231419
docs: design spec for botDM re-subscribe upsert + DisableNotification…
claude 002200f
docs: implementation plan for botDM re-subscribe upsert
claude 300f4e8
feat(model): add DisableNotification field to Subscription
claude 9348576
feat(room-worker): add BulkUpsertSubscriptions store method
claude 8b445fb
feat(room-worker): use BulkUpsertSubscriptions on async botDM path
claude d377e9c
feat(room-worker): use BulkUpsertSubscriptions on sync botDM path
claude 4717061
test(room-worker): integration test for botDM re-subscribe refresh
claude 13c1161
test(room-worker): regression test for regular-DM insert-only contract
claude aa6f2b8
refactor(room-worker): extract DM sub re-read helper, use mongoutil.U…
claude b5634fa
fix(room-worker): refresh JoinedAt on sync botDM re-subscribe
claude d4bb50c
refactor(room-worker): single-query DM sub pair + idempotent-upsert B…
claude be4e22c
docs: update botDM upsert spec/plan for post-review changes
claude 6ad27b0
refactor(room-worker): remove BulkUpsertSubscriptions; user-service o…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
809 changes: 809 additions & 0 deletions
809
docs/superpowers/plans/2026-05-19-botdm-resubscribe-upsert.md
Large diffs are not rendered by default.
Oops, something went wrong.
230 changes: 230 additions & 0 deletions
230
docs/superpowers/specs/2026-05-19-botdm-resubscribe-upsert-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| # BotDM Re-subscribe Upsert + `DisableNotification` Field | ||
|
|
||
| **Date:** 2026-05-19 | ||
| **Branch:** `claude/fix-room-notification-settings-RqyO1` | ||
| **Scope:** `pkg/model`, `room-worker` | ||
|
|
||
| ## Problem | ||
|
|
||
| A user can mute notifications on a botDM room (the user-facing toggle for that | ||
| field will land in a follow-up PR). When the same user later tries to "open the | ||
| app" again — which on the backend re-issues `chat.user.<account>.request.room.create` | ||
| or `chat.user.<account>.request.room.syncCreateDM` for the bot — the | ||
| subscription document already exists, so the current `BulkCreateSubscriptions` | ||
| swallows the duplicate-key error and returns silently. The pre-existing sub | ||
| keeps its old state, including `DisableNotification = true`, so the user | ||
| remains muted and the re-activation is a no-op. | ||
|
|
||
| The desired behaviour is: re-creating a botDM for a user who already has a | ||
| subscription **refreshes the mute/active state** on that subscription — | ||
| `DisableNotification` is cleared, `IsSubscribed` is set to `true`, and | ||
| `JoinedAt` is updated to the new acceptance timestamp. Runtime state | ||
| (`LastSeenAt`, `HasMention`, `ThreadUnread`, `Alert`) is preserved. | ||
|
|
||
| `IsSubscribed = true` only appears on the human side of a botDM today | ||
| (`inbox-worker/handler.go:241-246` hard-codes `false` everywhere else), so the | ||
| "re-join" semantics described above are conceptually botDM-only. All other | ||
| membership write paths (channel rooms, regular DMs, add-member) keep their | ||
| current safe insert-only behaviour, which is the right semantics for JetStream | ||
| redelivery idempotency in those flows. | ||
|
|
||
| ## Scope | ||
|
|
||
| In scope: | ||
|
|
||
| - Add `DisableNotification bool` to `model.Subscription`. | ||
| - Add a new store method `BulkUpsertSubscriptions` that implements the re-join | ||
| refresh semantics described above. | ||
| - Wire the two botDM call sites in `room-worker` to use the new method. | ||
| - Re-read canonical subs after the async-path upsert so the | ||
| `subscription.update` / `MemberAddEvent` fan-out carries persisted state. | ||
|
|
||
| Explicitly out of scope (deferred to follow-up PRs): | ||
|
|
||
| - A user-facing toggle endpoint to set `DisableNotification`. | ||
| - `notification-worker` filtering: this PR does **not** change | ||
| `notification-worker`. It will still fan notifications to every sub | ||
| regardless of `DisableNotification`. End-to-end mute behaviour is wired up | ||
| separately. | ||
| - Any change to channel-room, regular-DM, or add-member persistence semantics. | ||
|
|
||
| ## Design | ||
|
|
||
| ### 1. Model — `pkg/model/subscription.go` | ||
|
|
||
| Add a single field next to `Alert`: | ||
|
|
||
| ```go | ||
| Alert bool `json:"alert" bson:"alert"` | ||
| DisableNotification bool `json:"disableNotification" bson:"disableNotification"` | ||
| ``` | ||
|
|
||
| No `omitempty`. Matches the convention of `Alert` and `HasMention` and ensures | ||
| the bson document always carries an explicit value. Existing Mongo records | ||
| without the field decode to Go zero value (`false`) — the desired default | ||
| ("notifications enabled") — so no migration is required. | ||
|
|
||
| Extend the `Subscription` round-trip case in `pkg/model/model_test.go` to set | ||
| `DisableNotification: true` so JSON and BSON marshalling are covered. | ||
|
|
||
| ### 2. Subscription builder — `room-worker/handler.go` | ||
|
|
||
| `newSub` (`handler.go:1040`) constructs every subscription used by every | ||
| membership write path. The zero value of `DisableNotification` already gives | ||
| the desired default (`false`) on fresh inserts, so the `newSub` signature does | ||
| not change. Adding a parameter that is always `false` would be noise. | ||
|
|
||
| ### 3. Store — `room-worker/store.go` + `room-worker/store_mongo.go` | ||
|
|
||
| Add a new interface method alongside `BulkCreateSubscriptions`: | ||
|
|
||
| ```go | ||
| // BulkUpsertSubscriptions inserts the given subscriptions and, on a | ||
| // (roomId, u.account) collision with an existing document, refreshes the | ||
| // re-activation fields (DisableNotification → false, IsSubscribed, | ||
| // JoinedAt) while preserving the existing document's runtime state | ||
| // (LastSeenAt, HasMention, ThreadUnread, Alert) and identity (_id, u). | ||
| // Intended for botDM re-creation paths only; channel/DM/add-member paths | ||
| // must continue to use BulkCreateSubscriptions for safe redelivery | ||
| // idempotency. | ||
| BulkUpsertSubscriptions(ctx context.Context, subs []*model.Subscription) error | ||
| ``` | ||
|
|
||
| Implementation in `store_mongo.go` uses `BulkWrite` with one | ||
| `UpdateOneModel` per sub: | ||
|
|
||
| - **Filter:** `{"roomId": sub.RoomID, "u.account": sub.User.Account}`. | ||
| - **`$set`:** `disableNotification: false`, `isSubscribed: sub.IsSubscribed`, | ||
| `joinedAt: sub.JoinedAt`. These are the re-activation fields. | ||
| - **`$setOnInsert`:** `_id, u, roomId, siteId, roomType, name, roles, | ||
| hasMention: false, alert: false`. These are immutable identity fields plus | ||
| zero-value initialisers for the runtime fields, applied only on first | ||
| insert. | ||
| - **Upsert:** `true`. Unordered so partial collisions don't halt the batch. | ||
|
|
||
| On the update branch, runtime fields (`LastSeenAt`, `HasMention`, | ||
| `ThreadUnread`, `Alert`) are not in `$set`, so Mongo preserves whatever it | ||
| currently holds — an unread mention that arrived between mute and | ||
| re-subscribe is not erased. On the insert branch, `$setOnInsert` | ||
| initialises `hasMention` and `alert` to `false`; `lastSeenAt` and | ||
| `threadUnread` remain unset (their bson tags are `omitempty`). | ||
|
|
||
| `mock_store_test.go` is regenerated via `make generate`. | ||
|
|
||
| ### 4. Wire into botDM paths — `room-worker/handler.go` | ||
|
|
||
| Two call sites switch from `BulkCreateSubscriptions` to | ||
| `BulkUpsertSubscriptions`: | ||
|
|
||
| 1. `processCreateRoom` botDM branch — `handler.go:1163`. The regular-DM | ||
| branch immediately above (`buildDMSubs` at `handler.go:1161`) stays on | ||
| `BulkCreateSubscriptions`. | ||
| 2. `processSyncCreateDM` botDM branch — `handler.go:1568`. The regular-DM | ||
| path in the same function stays on `BulkCreateSubscriptions`. | ||
|
|
||
| Concretely, both sites become a conditional dispatch: | ||
|
|
||
| ```go | ||
| if roomType == model.RoomTypeBotDM { | ||
| err = h.store.BulkUpsertSubscriptions(ctx, subs) | ||
| } else { | ||
| err = h.store.BulkCreateSubscriptions(ctx, subs) | ||
| } | ||
| ``` | ||
|
|
||
| All other call sites (`processCreateRoomChannel` at `handler.go:1229`, | ||
| add-member flow) are unchanged. | ||
|
|
||
| ### 5. Re-read after upsert (async path) | ||
|
|
||
| The sync path (`processSyncCreateDM`) already re-reads via | ||
| `FindDMSubscription` after the bulk call (`handler.go:1574-1582`) so the | ||
| fan-out carries canonical `_id` and `JoinedAt`. No change there. | ||
|
|
||
| The async `processCreateRoom` botDM branch currently hands the in-memory | ||
| `subs` directly to `finishCreateRoom` (`handler.go:1166`). On an upsert that | ||
| hit an existing row, the in-memory `sub.ID` and `sub.JoinedAt` may not match | ||
| the persisted document. Mirror the sync path: after a successful | ||
| `BulkUpsertSubscriptions` in the async botDM branch, call | ||
| `FindDMSubscription` for `(requester.Account, counterpart.Account)` and | ||
| `(counterpart.Account, requester.Account)`, replace the in-memory subs with | ||
| the canonical pair, and pass that into `finishCreateRoom`. This keeps | ||
| `subscription.update` events, `MemberAddEvent`s, and reconcile counts | ||
| operating on persisted state. | ||
|
|
||
| ## Testing | ||
|
|
||
| Per CLAUDE.md, all new code goes through the Red-Green-Refactor TDD cycle. | ||
|
|
||
| ### Unit tests — `room-worker/handler_test.go` | ||
|
|
||
| - `processCreateRoom` botDM happy path: expect `BulkUpsertSubscriptions` | ||
| (not `BulkCreateSubscriptions`) and the two new `FindDMSubscription` | ||
| re-reads. Verify the events published downstream carry the re-read sub | ||
| values (different `_id` than the in-memory pre-upsert sub). | ||
| - `processCreateRoom` regular-DM, `processCreateRoomChannel`, and | ||
| add-member paths: regression-guard that they still expect | ||
| `BulkCreateSubscriptions` and **not** `BulkUpsertSubscriptions`. | ||
| - `processSyncCreateDM` botDM happy path: expect | ||
| `BulkUpsertSubscriptions`. Regular-DM branch still expects | ||
| `BulkCreateSubscriptions`. | ||
|
|
||
| ### Integration tests — `room-worker/integration_test.go` | ||
|
|
||
| - **Re-join refresh:** Pre-seed a botDM subscription with | ||
| `DisableNotification = true`, `IsSubscribed = false`, and an old | ||
| `JoinedAt`. Also set `HasMention = true`, `Alert = true`, and a | ||
| `LastSeenAt`. Run `processCreateRoom` for the same (roomID, account) | ||
| pair with a new `acceptedAt`. Assert: | ||
| - `_id` unchanged from the pre-seeded row. | ||
| - `DisableNotification == false`, `IsSubscribed == true`, | ||
| `JoinedAt == new acceptedAt`. | ||
| - `HasMention == true`, `Alert == true`, `LastSeenAt` unchanged | ||
| (runtime state preserved). | ||
| - **Fresh insert:** No pre-seeded row. Run `processCreateRoom` botDM. | ||
| Assert sub is created with `DisableNotification == false`, | ||
| `IsSubscribed == true` (human side) / `false` (bot side), | ||
| `HasMention == false`, `Alert == false`. | ||
| - **Regular-DM regression:** Pre-seed a regular-DM sub with | ||
| `DisableNotification = true`. Run `processCreateRoom` for the same | ||
| pair (regular DM, not botDM). Assert `DisableNotification` is still | ||
| `true` — regular-DM path does **not** upsert, so the pre-existing | ||
| state is preserved (current behaviour, regression guard). | ||
|
|
||
| ### Model tests | ||
|
|
||
| `pkg/model/model_test.go` Subscription round-trip case extended to set | ||
| `DisableNotification = true`, verifying both JSON and BSON tag correctness. | ||
|
|
||
| ### Coverage | ||
|
|
||
| Target ≥90% on the touched files (`room-worker/handler.go`, | ||
| `room-worker/store_mongo.go`) per the project's coverage rules. Cover both | ||
| the upsert-as-insert and upsert-as-update branches, the re-read failure | ||
| path, and the regular-DM no-upsert regression. | ||
|
|
||
| ## Risks & Open Questions | ||
|
|
||
| - **Concurrent mute + re-subscribe race.** If the user mutes | ||
| (`DisableNotification = true`) at the same instant they re-open the app | ||
| (triggering this upsert), the upsert can overwrite the just-muted value. | ||
| Mitigation: the user-facing mute endpoint (out of scope here) lands later | ||
| and will be the dominant source of mutes; an accidental clear from a | ||
| racing re-creation is recoverable by re-muting. Calling this out so the | ||
| follow-up PR's design can address it if needed. | ||
|
|
||
| - **`JoinedAt` semantics drift.** Refreshing `JoinedAt` on every botDM | ||
| re-creation means it is now "last re-join time", not "first join time". | ||
| No current consumer treats `JoinedAt` as "first join" for botDMs, but | ||
| worth flagging in case downstream analytics rely on it. | ||
|
|
||
| ## Out of Scope (Follow-Up PRs) | ||
|
|
||
| - User-facing endpoint to toggle `DisableNotification` (probably an HTTP | ||
| PATCH on the subscription, or a NATS RPC; routed through `room-worker` | ||
| or `auth-service`). | ||
| - `notification-worker` filter: skip subs where | ||
| `DisableNotification == true` before fan-out. | ||
| - Frontend mute toggle UI. | ||
| - `docs/client-api.md` update — only required once the client-facing | ||
| toggle endpoint lands (this PR exposes no new request/response schema). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should apply bulkUpsert for both bot dm and user dm because room-worker might also run into duplicate key issue during reprocess due to Jetstream msg redelivery