diff --git a/docs/client-api.md b/docs/client-api.md index 606cb1247..b75af40dd 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -393,10 +393,10 @@ Error example (e.g. requester not in room): |----------------|--------|-------| | `userId` | string | The affected user's internal user ID. Omitted on the org-removal path (only `subscription.u.account` is set there). | | `subscription` | object | For `added` / `role_updated`: the full `Subscription` record (see below). For `removed`: a lean ref carrying only `roomId`, `roomType`, and `u` (see Remove Member). | -| `action` | string | `"added"`, `"removed"`, `"role_updated"`, or `"mute_toggled"`. | +| `action` | string | `"added"`, `"removed"`, `"role_updated"`, `"mute_toggled"`, or `"favorite_toggled"`. | | `timestamp` | number | Milliseconds since Unix epoch (UTC). | -On `added` / `role_updated` / `mute_toggled` the embedded `Subscription` serializes its ID as `id` (not `_id`) and the user under `u` (not `user`). Non-`omitempty` fields (`id`, `u`, `roomId`, `siteId`, `roles`, `name`, `roomType`, `joinedAt`, `hasMention`, `alert`, `muted`) are always present. `removed` events use a dedicated lean payload (`SubscriptionRemovedEvent`) whose `subscription` carries **only** `roomId`, `roomType`, and `u` — no zero-valued `Subscription` fields are sent. +On `added` / `role_updated` / `mute_toggled` / `favorite_toggled` the embedded `Subscription` serializes its ID as `id` (not `_id`) and the user under `u` (not `user`). Non-`omitempty` fields (`id`, `u`, `roomId`, `siteId`, `roles`, `name`, `roomType`, `joinedAt`, `hasMention`, `alert`, `muted`, `favorite`) are always present. `removed` events use a dedicated lean payload (`SubscriptionRemovedEvent`) whose `subscription` carries **only** `roomId`, `roomType`, and `u` — no zero-valued `Subscription` fields are sent. ```json { @@ -816,6 +816,56 @@ See [Error envelope](#6-error-envelope-reference). Common errors: --- +#### Toggle Favorite + +**Subject:** `chat.user.{account}.request.room.{roomID}.{siteID}.favorite.toggle` +**Reply subject:** auto-generated `_INBOX.>` (NATS request/reply) + +- `{siteID}` must be the room's **origin `siteID`** (the site that owns the room), not the caller's own site. + +Synchronous RPC. `room-service` flips `Subscription.favorite` for the requester in a single atomic Mongo `FindOneAndUpdate`, replies with the resulting value, and fans out a `subscription.update` event to the user's other client sessions. Used by the client to render the per-user "favorited" sidebar section; backend treats the flag as a render hint only — no downstream behaviour (notifications, routing, retention) is gated on it. + +Idempotency: this is a toggle, not a set — every successful call flips the bit. Clients must debounce the user-visible action; redelivery of the same RPC will flip back. + +##### Request body + +The subject already carries `account` and `roomID`, so no body fields are required. Clients may send `{}` or omit the body entirely; any body content is ignored. + +##### Success response + +| Field | Type | Notes | +|------------|---------|-------| +| `status` | string | Always `"ok"`. | +| `favorite` | boolean | The resulting value of `Subscription.favorite` after the flip. | + +```json +{ "status": "ok", "favorite": true } +``` + +##### Error response + +See [Error envelope](#6-error-envelope-reference). Common errors: + +- `"only room members can list members"` — the user has no subscription in the room (sentinel reused across membership-gated RPCs). +- `"invalid favorite-toggle subject: …"` — the subject is malformed. + +##### Triggered events — success path + +**`chat.user.{account}.event.subscription.update`** — emitted once for the requester so other client sessions reconcile. + +| Field | Type | Notes | +|----------------|--------|-------| +| `userId` | string | The requester's internal user ID. | +| `subscription` | object | The `Subscription` record with the updated `favorite`. | +| `action` | string | `"favorite_toggled"`. | +| `timestamp` | number | Milliseconds since Unix epoch (UTC). | + +##### Cross-site behaviour + +When the requester's home site differs from the room's site, `room-service` additionally publishes a `subscription_favorite_toggled` OutboxEvent to `outbox.{roomSite}.to.{userSite}.subscription_favorite_toggled`. `inbox-worker` on the user's home site mirrors the flip onto the local `Subscription` document. Missing-subscription on the home site (e.g., a federation race) is a silent no-op — no NACK, no redelivery loop. + +--- + #### Read Message Receipts **Subject:** `chat.user.{account}.request.room.{roomID}.{siteID}.message.read-receipt` diff --git a/docs/superpowers/specs/2026-06-01-favorite-toggle-rpc-design.md b/docs/superpowers/specs/2026-06-01-favorite-toggle-rpc-design.md new file mode 100644 index 000000000..3b23b2f03 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-favorite-toggle-rpc-design.md @@ -0,0 +1,366 @@ +# Favorite-Toggle RPC in room-service + +## Summary + +A new client-facing NATS request/reply RPC, `favorite.toggle`, that flips +`Subscription.favorite` for the requester on a single room. The flag is a +per-user, per-room boolean intended as a sidebar render hint for a +"favorites" section — backend treats it as opaque. No notification gating, +no fan-out routing changes, no message filtering. + +Architecturally mirrors `mute.toggle` (PR #217): same RPC shape, same store +pattern, same cross-site replication path. The mute and favorite flags live +side by side on `Subscription` and are mutated by parallel handlers; no +shared validation or coupling between them. + +## Subject + +- Concrete: `chat.user.{account}.request.room.{roomID}.{siteID}.favorite.toggle` +- Wildcard: `chat.user.*.request.room.*.{siteID}.favorite.toggle` + +`{siteID}` is the room's origin site — the same site that owns the room +and that runs the `room-service` handling this RPC. Queue group: +`room-service`. Subject parsing reuses `subject.ParseUserRoomSubject`. + +## Wire Format + +`pkg/model/event.go`: + +```go +// FavoriteToggleResponse is the sync reply for the favorite.toggle RPC. +type FavoriteToggleResponse struct { + Status string `json:"status"` // always "ok" on success + Favorite bool `json:"favorite"` // post-flip value +} + +// SubscriptionFavoriteToggledEvent is the OutboxEvent.Payload for type +// "subscription_favorite_toggled". +type SubscriptionFavoriteToggledEvent struct { + Account string `json:"account" bson:"account"` + RoomID string `json:"roomId" bson:"roomId"` + Favorite bool `json:"favorite" bson:"favorite"` + Timestamp int64 `json:"timestamp" bson:"timestamp"` // UnixMilli UTC +} +``` + +Outbox event-type constant: + +```go +const OutboxSubscriptionFavoriteToggled OutboxEventType = "subscription_favorite_toggled" +``` + +Request body: empty. The subject already carries `account` and `roomID`; +any body content sent by the client is ignored. + +## Subscription model + +`pkg/model/subscription.go` gains one field: + +```go +Favorite bool `json:"favorite" bson:"favorite"` +``` + +Both tags are always present (no `omitempty`) — `false` is serialized +explicitly so clients can distinguish "not favorited" from "unknown". + +The accompanying `SubscriptionUpdateEvent.Action` enum gains +`"favorite_toggled"` alongside the existing `"added"`, `"removed"`, +`"role_updated"`, `"mute_toggled"`. + +## Subject Builders + +`pkg/subject/subject.go`: + +```go +func FavoriteToggle(account, roomID, siteID string) string { + return fmt.Sprintf("chat.user.%s.request.room.%s.%s.favorite.toggle", account, roomID, siteID) +} + +func FavoriteToggleWildcard(siteID string) string { + return fmt.Sprintf("chat.user.*.request.room.*.%s.favorite.toggle", siteID) +} +``` + +## Handler Flow + +`room-service/handler.go`: + +1. `Register` adds + `nc.QueueSubscribe(subject.FavoriteToggleWildcard(h.siteID), "room-service", h.natsFavoriteToggle)`. +2. `natsFavoriteToggle` wraps context, delegates to `handleFavoriteToggle`, + sanitises errors via `natsutil.ReplyError`, success via `m.Msg.Respond`. +3. `handleFavoriteToggle(ctx, subj, _)`: + 1. Parse subject → `(account, roomID)` via `subject.ParseUserRoomSubject`. + Malformed subject → `fmt.Errorf("invalid favorite-toggle subject: %s", subj)`. + 2. Tracing: set `room.id` and `site.id` attributes on the active span. + 3. `sub, err := h.store.ToggleSubscriptionFavorite(ctx, roomID, account)`. + - `errors.Is(err, model.ErrSubscriptionNotFound)` → `errNotRoomMember`. + - Other errors → `fmt.Errorf("toggle subscription favorite: %w", err)`. + 4. Publish `SubscriptionUpdateEvent` on `subject.SubscriptionUpdate(account)` + with `Action: "favorite_toggled"`, `Subscription: *sub`. **Publish + failure here is non-fatal** — slog.Error and continue. Rationale: the + DB write is the source of truth and other client sessions reconcile + on their next subscription refetch. + 5. `userSiteID, err := h.store.GetUserSiteID(ctx, account)`. + Error → `fmt.Errorf("get user siteId: %w", err)`. + 6. If `userSiteID != "" && userSiteID != h.siteID`: + - Build `SubscriptionFavoriteToggledEvent` payload. + - Wrap in `OutboxEvent{Type: OutboxSubscriptionFavoriteToggled, …}`. + - Publish to `subject.Outbox(h.siteID, userSiteID, OutboxSubscriptionFavoriteToggled)` + via `publishToStream` (JetStream OUTBOX). Failure here **is** fatal — + client gets an error, federation will retry on next user action. + 7. Reply `FavoriteToggleResponse{Status: "ok", Favorite: sub.Favorite}`. + +Idempotency: this is a **toggle**, not a set. Every successful call flips +the bit. Clients must debounce the user action; the handler does not +deduplicate. The same is true of `mute.toggle`. + +## Errors + +Reuses existing room-service sentinels — no new error variables: + +- `errNotRoomMember` (already defined in `room-service/helper.go`, + message `"only room members can list members"`) — returned when the + requester has no subscription in the room. + +All errors are routed through `sanitizeError` before reaching the client. + +## Store + +### Interface (`room-service/store.go`) + +```go +// ToggleSubscriptionFavorite atomically flips favorite via a single +// FindOneAndUpdate. Returns the post-flip subscription, or +// model.ErrSubscriptionNotFound (wrapped) when no match. +ToggleSubscriptionFavorite(ctx context.Context, roomID, account string) (*model.Subscription, error) +``` + +`GetUserSiteID` already exists — reused as-is. + +### Mongo implementation (`room-service/store_mongo.go`) + +```go +filter := bson.M{"roomId": roomID, "u.account": account} +update := mongo.Pipeline{ + bson.D{{Key: "$set", Value: bson.M{ + "favorite": bson.M{"$not": bson.A{ + bson.M{"$ifNull": bson.A{"$favorite", false}}, + }}, + }}}, +} +opts := options.FindOneAndUpdate().SetReturnDocument(options.After) +``` + +Two key properties of this pipeline: + +1. **Atomic** — single round-trip, no read-modify-write race. +2. **Legacy-safe** — `$ifNull: ["$favorite", false]` treats documents + written before this change (no `favorite` key at all) as + `false`, so the first toggle deterministically flips them to `true`. + No migration script required. + +`mongo.ErrNoDocuments` is wrapped with `model.ErrSubscriptionNotFound`. +All other Mongo errors are wrapped with context. + +### Indexes + +No new indexes. The existing `(roomId, "u.account")` compound index from +the `mute.toggle` work covers this lookup unchanged. + +## Cross-Site Federation + +Mirrors `subscription_mute_toggled`. The room's site is the write site; +the user's home site is the destination. + +### Outbox publish (room-service) + +Subject: `outbox.{roomSite}.to.{userSite}.subscription_favorite_toggled`. +Payload: `OutboxEvent` whose inner `Payload` is the JSON-encoded +`SubscriptionFavoriteToggledEvent`. JetStream stream: +`OUTBOX_{roomSite}` (already provisioned for mute and other cross-site +events). + +### Inbox handler (inbox-worker) + +`inbox-worker/handler.go`: + +1. Dispatch switch gains + `case "subscription_favorite_toggled": return h.handleSubscriptionFavoriteToggled(ctx, &evt)`. +2. `handleSubscriptionFavoriteToggled`: + 1. Unmarshal `evt.Payload` into `SubscriptionFavoriteToggledEvent`. + 2. Call `h.store.UpdateSubscriptionFavorite(ctx, e.RoomID, e.Account, e.Favorite)`. + +`InboxStore` interface gains: + +```go +UpdateSubscriptionFavorite(ctx context.Context, roomID, account string, favorite bool) error +``` + +Mongo implementation (`inbox-worker/main.go`): + +```go +_, err := s.subCol.UpdateOne(ctx, + bson.M{"roomId": roomID, "u.account": account}, + bson.M{"$set": bson.M{"favorite": favorite}}, +) +``` + +Missing-subscription (zero documents matched) is a **silent no-op**, not +an error. Rationale: cross-site federation races — the user may have left +the room between the toggle on the room-site and the mirror on the +home-site. NACK'ing here would create a poison-pill redelivery loop with +no recovery path. Same pattern as `UpdateSubscriptionMute`. + +### Stream ownership + +No `bootstrap.go` change in either service. `OUTBOX_{site}` and +`INBOX_{site}` are owned by ops/IaC (CLAUDE.md §6 — "Stream bootstrap is +opt-in"). `inbox-worker` already owns INBOX creation when running in +dev with `BOOTSTRAP_STREAMS=true`. + +## Behavioural rules + +These are deliberate choices, not omissions: + +1. **notification-worker does not consult `favorite`.** Favoriting a + room does not affect push/email/banner delivery. (Compare: muting + *also* does not yet gate notifications — both are render-only for now. + If favorites ever need to gate behaviour, it's a separate change.) +2. **broadcast-worker does not route differently.** Favorites do not + change which sessions receive a message. +3. **message-gatekeeper is untouched.** Validation does not consider + subscription favourites. +4. **No bulk/list RPC.** Clients render the favorites section by + filtering their existing in-memory subscription list on the + `favorite` field. No new endpoint required. +5. **No DM/channel distinction.** Any subscription is favouritable — + channel, DM, botDM, discussion. The handler doesn't branch on + `RoomType`. + +## Client API Doc + +Per CLAUDE.md §5, the same PR updates `docs/client-api.md`: + +- New section "Toggle Favorite" under user-scoped RPCs, sibling to + "Toggle Mute". Documents subject, empty request body, + `FavoriteToggleResponse`, error cases, triggered `subscription.update` + event, and cross-site outbox-mirror behaviour. +- The existing "Subscription Update" event section's `action` enum + documentation gains `"favorite_toggled"`, and the present-fields list + for the embedded `Subscription` adds `favorite`. + +## Testing (TDD) + +### Subject builders (`pkg/subject/subject_test.go`) + +- `TestFavoriteToggle` — concrete-subject builder. +- `TestFavoriteToggleWildcard` — wildcard form. +- `TestFavoriteToggle_ParseUserRoomSubject` — round-trip with the shared + parser. + +### Model round-trip (`pkg/model/model_test.go`) + +- Extend the existing `Subscription` round-trip case to populate + `Favorite: true`. +- Extend `TestSubscriptionJSON_ThreadUnreadOmittedAlertAlwaysPresent` to + assert `"favorite"` is present in the JSON even when `false` (parallel + to the existing `"muted"` assertion). +- `TestFavoriteToggleResponseJSON` — happy-path round-trip + raw-map + assertion on `{status, favorite}`. +- `TestSubscriptionFavoriteToggledEventJSON` — round-trip. +- `TestOutboxSubscriptionFavoriteToggledConst` — exact-string + `"subscription_favorite_toggled"`. + +### Handler unit tests (`room-service/handler_test.go`) + +Eight tests, parallel to the mute suite. Each builds a `Handler` with +mocked `RoomStore` and captured `publishCore` / `publishToStream` +closures. + +| Test | Setup | Asserts | +|------|-------|---------| +| `TestHandler_FavoriteToggle_Success` | store returns sub with `Favorite: true`, `GetUserSiteID` returns `"site-a"` (same site) | Reply `{ok, true}`. One core publish on `chat.user.alice.event.subscription.update` with `Action: "favorite_toggled"`. **No** stream publish (`t.Fatal` guard in stub). | +| `TestHandler_FavoriteToggle_CrossSitePublishesOutbox` | store returns sub, `GetUserSiteID` returns `"site-b"` | Stream publish on `outbox.site-a.to.site-b.subscription_favorite_toggled`. `OutboxEvent` decoded carries `Type=OutboxSubscriptionFavoriteToggled`, `SiteID="site-a"`, `DestSiteID="site-b"`. Inner payload decodes to `SubscriptionFavoriteToggledEvent{Account, RoomID, Favorite, NonZero Timestamp}`. | +| `TestHandler_FavoriteToggle_NotRoomMember` | store returns `model.ErrSubscriptionNotFound` | `errors.Is(err, errNotRoomMember)`. No publishes. | +| `TestHandler_FavoriteToggle_InvalidSubject` | call handler with `"garbage.subject"` | Error contains `"invalid favorite-toggle subject"`. Store not called. | +| `TestHandler_FavoriteToggle_StoreError` | store returns `fmt.Errorf("db down")` | Error contains `"toggle subscription favorite"`. No publishes. | +| `TestHandler_FavoriteToggle_GetUserSiteIDError` | toggle ok, `GetUserSiteID` fails | Error contains `"get user siteId"`. Stream publish guarded by `t.Fatal`. | +| `TestHandler_FavoriteToggle_CrossSiteOutboxPublishFailure` | cross-site case, `publishToStream` returns error | Error contains `"publish favorite-toggled outbox"`. Client sees failure; favorite IS persisted (write happened before publish). | +| `TestHandler_FavoriteToggle_CorePublishFailureIsNonFatal` | same-site case, `publishCore` returns error | `require.NoError` — handler still replies `{ok, true}`. Documents the non-fatal contract. | + +Mocks: `make generate SERVICE=room-service` regenerates `mock_store_test.go` +with `ToggleSubscriptionFavorite`. `GetUserSiteID` mock already exists. + +### Inbox-worker unit tests (`inbox-worker/handler_test.go`) + +Three tests on the same `stubInboxStore` used by the mute tests, extended +with a `UpdateSubscriptionFavorite` method that mutates the in-memory +subscription slice and silently no-ops on missing-sub. + +- `TestHandler_SubscriptionFavoriteToggled` — happy path, asserts the + store's subscription has `Favorite: true` after `HandleEvent`. +- `TestHandler_SubscriptionFavoriteToggled_MissingSubscriptionNoOp` — + empty store, payload references unknown account, `HandleEvent` returns + `nil`. +- `TestHandler_SubscriptionFavoriteToggled_MalformedPayload` — + `Payload: []byte("not-json")`, `HandleEvent` returns error so JetStream + redelivers (the wrapping NACK path). + +### Integration test (`room-service/integration_test.go`) + +Tagged `//go:build integration`. One test, `TestMongoStore_ToggleSubscriptionFavorite`: + +1. Insert a raw BSON subscription with **no** `favorite` key at all — + `bson.M` directly, bypassing the Go struct. This proves the legacy-doc + path: documents written before this change must toggle cleanly. +2. First toggle → assert returned `Favorite == true` and `GetSubscription` + reads back `true`. Confirms `$ifNull` branch. +3. Second toggle → assert `Favorite == false`. +4. Toggle on `(roomID="missing", account)` → assert + `errors.Is(err, model.ErrSubscriptionNotFound)` and nil sub. + +Uses `testutil.MongoDB(t, "room-svc-fav")` for an isolated DB per test. + +### Coverage + +The new handler and store methods each hit every branch the table above +enumerates. Expected ≥90% on the new code; project floor of 80% applies +to the whole package. + +## Out of Scope + +- Frontend changes. The frontend will read `favorite` off `Subscription` + and render a sidebar section in a separate change; this PR is backend-only. +- Bulk-favorite RPC (e.g., "favourite all DMs"). Clients call + `favorite.toggle` per-room. +- Default-favorite-on-join policy. Subscriptions are created with + `favorite=false` implicitly (zero value); no constructor change. +- Persistence cross-device sort order. The flag is a boolean; ordering + within the "favorites" section is the frontend's concern. +- Notification gating, broadcast routing changes, retention policy + changes. None. +- Stream provisioning. `OUTBOX`/`INBOX` are pre-existing; no + `bootstrap.go` change in either service. + +## Risks + +- **Toggle vs set semantics.** A retried RPC inverts state. Mute has + this same property and clients debounce there; the favorite UI must do + the same. Documented in `client-api.md`. +- **Cross-site mirror lag.** Between the room-site write and the + home-site mirror, a "list my subscriptions" call against the home site + will return the old value. Acceptable — the local subscription cache on + the requester's session already reflects the new value (it received the + `subscription.update` event synchronously), and other devices reconcile + on next refetch. +- **Federation race silent no-op.** If a user leaves the room between + the toggle and the inbox mirror, the mirror silently no-ops. The + invariant — favorite makes no sense for a non-member — holds. No + observable user impact. +- **No mute/favorite atomicity.** Mute and favorite are independent + atomic toggles. Toggling both "simultaneously" from a client requires + two RPCs that may interleave with other writers. Not a problem in + practice (the fields don't constrain each other) but called out so a + future "bulk subscription mutation" RPC doesn't accidentally assume + the two were ever coupled. diff --git a/inbox-worker/handler.go b/inbox-worker/handler.go index abc83e933..0e15ede74 100644 --- a/inbox-worker/handler.go +++ b/inbox-worker/handler.go @@ -34,6 +34,8 @@ type InboxStore interface { ApplyThreadRead(ctx context.Context, roomID, threadRoomID, account string, newThreadUnread []string, alert bool, lastSeenAt time.Time) error // UpdateSubscriptionMute sets muted by (roomID, account); missing-sub is a silent no-op for federation races. UpdateSubscriptionMute(ctx context.Context, roomID, account string, muted bool) error + // UpdateSubscriptionFavorite silently no-ops on missing-sub (federation race — user left mid-flight). + UpdateSubscriptionFavorite(ctx context.Context, roomID, account string, favorite bool) error } // Handler processes cross-site OutboxEvent messages; replicates only subscription/room metadata, never room keys. @@ -66,6 +68,8 @@ func (h *Handler) HandleEvent(ctx context.Context, data []byte) error { return h.handleSubscriptionRead(ctx, &evt) case "subscription_mute_toggled": return h.handleSubscriptionMuteToggled(ctx, &evt) + case "subscription_favorite_toggled": + return h.handleSubscriptionFavoriteToggled(ctx, &evt) case "thread_subscription_upserted": return h.handleThreadSubscriptionUpserted(ctx, &evt) case "thread_read": @@ -224,6 +228,18 @@ func (h *Handler) handleSubscriptionMuteToggled(ctx context.Context, evt *model. return nil } +// handleSubscriptionFavoriteToggled mirrors a room-side favorite toggle onto the user's home-site subscription. +func (h *Handler) handleSubscriptionFavoriteToggled(ctx context.Context, evt *model.OutboxEvent) error { + var e model.SubscriptionFavoriteToggledEvent + if err := json.Unmarshal(evt.Payload, &e); err != nil { + return fmt.Errorf("unmarshal subscription_favorite_toggled payload: %w", err) + } + if err := h.store.UpdateSubscriptionFavorite(ctx, e.RoomID, e.Account, e.Favorite); err != nil { + return fmt.Errorf("update subscription favorite for %q in room %q: %w", e.Account, e.RoomID, err) + } + return nil +} + // handleThreadSubscriptionUpserted upserts a ThreadSubscription on the local // site when message-worker on another site reports that a user (parent author, // replier, or mentionee) is participating in a thread. The Mongo store layer diff --git a/inbox-worker/handler_test.go b/inbox-worker/handler_test.go index e5efd2639..47c115027 100644 --- a/inbox-worker/handler_test.go +++ b/inbox-worker/handler_test.go @@ -168,6 +168,18 @@ func (s *stubInboxStore) UpdateSubscriptionMute(_ context.Context, roomID, accou return nil // missing-subscription → no-op } +func (s *stubInboxStore) UpdateSubscriptionFavorite(_ context.Context, roomID, account string, favorite bool) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.subscriptions { + if s.subscriptions[i].RoomID == roomID && s.subscriptions[i].User.Account == account { + s.subscriptions[i].Favorite = favorite + return nil + } + } + return nil // missing-subscription → no-op +} + func (s *stubInboxStore) UpdateSubscriptionRead(_ context.Context, roomID, account string, lastSeenAt time.Time, alert bool) error { s.mu.Lock() defer s.mu.Unlock() @@ -1376,3 +1388,62 @@ func TestHandler_SubscriptionMuteToggled_MalformedPayload(t *testing.T) { require.Error(t, h.HandleEvent(context.Background(), evt)) } + +func TestHandler_SubscriptionFavoriteToggled(t *testing.T) { + store := &stubInboxStore{ + subscriptions: []model.Subscription{ + { + ID: "s1", + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, + RoomID: "r1", + }, + }, + } + h := NewHandler(store) + + payload, err := json.Marshal(model.SubscriptionFavoriteToggledEvent{ + Account: "alice", RoomID: "r1", Favorite: true, Timestamp: 12345, + }) + require.NoError(t, err) + evt, err := json.Marshal(model.OutboxEvent{ + Type: model.OutboxSubscriptionFavoriteToggled, SiteID: "site-a", DestSiteID: "site-b", + Payload: payload, Timestamp: 12345, + }) + require.NoError(t, err) + + require.NoError(t, h.HandleEvent(context.Background(), evt)) + + subs := store.getSubscriptions() + require.Len(t, subs, 1) + assert.True(t, subs[0].Favorite) +} + +func TestHandler_SubscriptionFavoriteToggled_MissingSubscriptionNoOp(t *testing.T) { + store := &stubInboxStore{} + h := NewHandler(store) + + payload, err := json.Marshal(model.SubscriptionFavoriteToggledEvent{ + Account: "ghost", RoomID: "r1", Favorite: true, Timestamp: 12345, + }) + require.NoError(t, err) + evt, err := json.Marshal(model.OutboxEvent{ + Type: model.OutboxSubscriptionFavoriteToggled, SiteID: "site-a", DestSiteID: "site-b", + Payload: payload, Timestamp: 12345, + }) + require.NoError(t, err) + + require.NoError(t, h.HandleEvent(context.Background(), evt)) +} + +func TestHandler_SubscriptionFavoriteToggled_MalformedPayload(t *testing.T) { + store := &stubInboxStore{} + h := NewHandler(store) + + evt, err := json.Marshal(model.OutboxEvent{ + Type: model.OutboxSubscriptionFavoriteToggled, + Payload: []byte("not-json"), + }) + require.NoError(t, err) + + require.Error(t, h.HandleEvent(context.Background(), evt)) +} diff --git a/inbox-worker/main.go b/inbox-worker/main.go index 1d9de469f..e6b60cf06 100644 --- a/inbox-worker/main.go +++ b/inbox-worker/main.go @@ -128,6 +128,18 @@ func (s *mongoInboxStore) UpdateSubscriptionMute(ctx context.Context, roomID, ac return nil } +// UpdateSubscriptionFavorite sets favorite by (roomID, account); missing is a silent no-op. +func (s *mongoInboxStore) UpdateSubscriptionFavorite(ctx context.Context, roomID, account string, favorite bool) error { + _, err := s.subCol.UpdateOne(ctx, + bson.M{"roomId": roomID, "u.account": account}, + bson.M{"$set": bson.M{"favorite": favorite}}, + ) + if err != nil { + return fmt.Errorf("update subscription favorite for %q in room %q: %w", account, roomID, err) + } + return nil +} + func (s *mongoInboxStore) UpdateSubscriptionRead(ctx context.Context, roomID, account string, lastSeenAt time.Time, alert bool) error { filter := bson.M{ "roomId": roomID, diff --git a/pkg/model/event.go b/pkg/model/event.go index c483bf2af..cb9c465c4 100644 --- a/pkg/model/event.go +++ b/pkg/model/event.go @@ -34,7 +34,7 @@ type RoomMetadataUpdateEvent struct { type SubscriptionUpdateEvent struct { UserID string `json:"userId"` Subscription Subscription `json:"subscription"` - Action string `json:"action"` // "added" | "removed" | "role_updated" | "mute_toggled" + Action string `json:"action"` // "added" | "removed" | "role_updated" | "mute_toggled" | "favorite_toggled" Timestamp int64 `json:"timestamp" bson:"timestamp"` } @@ -81,12 +81,13 @@ type NotificationEvent struct { type OutboxEventType = string const ( - OutboxMemberAdded OutboxEventType = "member_added" - OutboxMemberRemoved OutboxEventType = "member_removed" - OutboxSubscriptionRead OutboxEventType = "subscription_read" - OutboxSubscriptionMuteToggled OutboxEventType = "subscription_mute_toggled" - OutboxThreadSubscriptionUpserted OutboxEventType = "thread_subscription_upserted" - OutboxThreadRead OutboxEventType = "thread_read" + OutboxMemberAdded OutboxEventType = "member_added" + OutboxMemberRemoved OutboxEventType = "member_removed" + OutboxSubscriptionRead OutboxEventType = "subscription_read" + OutboxSubscriptionMuteToggled OutboxEventType = "subscription_mute_toggled" + OutboxSubscriptionFavoriteToggled OutboxEventType = "subscription_favorite_toggled" + OutboxThreadSubscriptionUpserted OutboxEventType = "thread_subscription_upserted" + OutboxThreadRead OutboxEventType = "thread_read" ) // SubscriptionReadEvent is the OutboxEvent.Payload for type @@ -313,6 +314,20 @@ type SubscriptionMuteToggledEvent struct { Timestamp int64 `json:"timestamp" bson:"timestamp"` } +// FavoriteToggleResponse is the sync reply for the favorite.toggle RPC. +type FavoriteToggleResponse struct { + Status string `json:"status"` + Favorite bool `json:"favorite"` +} + +// SubscriptionFavoriteToggledEvent is the OutboxEvent.Payload for type "subscription_favorite_toggled". +type SubscriptionFavoriteToggledEvent struct { + Account string `json:"account" bson:"account"` + RoomID string `json:"roomId" bson:"roomId"` + Favorite bool `json:"favorite" bson:"favorite"` + Timestamp int64 `json:"timestamp" bson:"timestamp"` +} + type MemberRemoveEvent struct { Type string `json:"type" bson:"type"` RoomID string `json:"roomId" bson:"roomId"` diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 0e1c898d0..930baaa1a 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -504,33 +504,6 @@ func TestSubscriptionJSON(t *testing.T) { roundTrip(t, &s, &model.Subscription{}) }) - - t.Run("favorite omitted when false", func(t *testing.T) { - s := model.Subscription{ - ID: "s1", - User: model.SubscriptionUser{ID: "u1", Account: "alice"}, - RoomID: "r1", - RoomType: model.RoomTypeDM, - SiteID: "site-a", - JoinedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), - } - data, err := json.Marshal(&s) - require.NoError(t, err) - - var raw map[string]any - require.NoError(t, json.Unmarshal(data, &raw)) - _, present := raw["favorite"] - assert.False(t, present, "favorite should be omitted when false") - - bdata, err := bson.Marshal(&s) - require.NoError(t, err) - var braw bson.M - require.NoError(t, bson.Unmarshal(bdata, &braw)) - _, bpresent := braw["favorite"] - assert.False(t, bpresent, "favorite should be omitted from BSON when false") - - roundTrip(t, &s, &model.Subscription{}) - }) } func TestSubscriptionJSON_ThreadUnreadOmittedAlertAlwaysPresent(t *testing.T) { @@ -561,10 +534,15 @@ func TestSubscriptionJSON_ThreadUnreadOmittedAlertAlwaysPresent(t *testing.T) { assert.True(t, hasMuted, "muted must be present in JSON even when false") assert.Equal(t, false, mutedVal) + favoriteVal, hasFavorite := raw["favorite"] + assert.True(t, hasFavorite, "favorite must be present in JSON even when false") + assert.Equal(t, false, favoriteVal) + var dst model.Subscription require.NoError(t, json.Unmarshal(data, &dst)) assert.Nil(t, dst.ThreadUnread, "absent threadUnread must unmarshal to nil") assert.False(t, dst.Alert) + assert.False(t, dst.Favorite) } func TestDMSubscriptionJSON_EmbeddedFlattensWithHRInfo(t *testing.T) { @@ -2330,6 +2308,43 @@ func TestOutboxSubscriptionMuteToggledConst(t *testing.T) { assert.Equal(t, model.OutboxEventType("subscription_mute_toggled"), model.OutboxSubscriptionMuteToggled) } +func TestFavoriteToggleResponseJSON(t *testing.T) { + src := model.FavoriteToggleResponse{ + Status: "ok", + Favorite: true, + } + data, err := json.Marshal(src) + require.NoError(t, err) + + var dst model.FavoriteToggleResponse + require.NoError(t, json.Unmarshal(data, &dst)) + assert.Equal(t, src, dst) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + assert.Equal(t, "ok", raw["status"]) + assert.Equal(t, true, raw["favorite"]) +} + +func TestSubscriptionFavoriteToggledEventJSON(t *testing.T) { + src := model.SubscriptionFavoriteToggledEvent{ + Account: "alice", + RoomID: "r1", + Favorite: true, + Timestamp: 1234567890, + } + data, err := json.Marshal(src) + require.NoError(t, err) + + var dst model.SubscriptionFavoriteToggledEvent + require.NoError(t, json.Unmarshal(data, &dst)) + assert.Equal(t, src, dst) +} + +func TestOutboxSubscriptionFavoriteToggledConst(t *testing.T) { + assert.Equal(t, model.OutboxEventType("subscription_favorite_toggled"), model.OutboxSubscriptionFavoriteToggled) +} + func TestSyncCreateDMRequestJSON(t *testing.T) { src := model.SyncCreateDMRequest{ RoomType: model.RoomTypeDM, diff --git a/pkg/model/subscription.go b/pkg/model/subscription.go index dfb3a04e9..03c9624dd 100644 --- a/pkg/model/subscription.go +++ b/pkg/model/subscription.go @@ -41,7 +41,7 @@ type Subscription struct { ThreadUnread []string `json:"threadUnread,omitempty" bson:"threadUnread,omitempty"` Alert bool `json:"alert" bson:"alert"` Muted bool `json:"muted" bson:"muted"` - Favorite bool `json:"favorite,omitempty" bson:"favorite,omitempty"` + Favorite bool `json:"favorite" bson:"favorite"` } // SubscriptionHRInfo carries the counterpart's HR-directory record on a diff --git a/pkg/subject/subject.go b/pkg/subject/subject.go index 1080dfe47..a5ae2fa47 100644 --- a/pkg/subject/subject.go +++ b/pkg/subject/subject.go @@ -413,6 +413,16 @@ func MuteToggleWildcard(siteID string) string { return fmt.Sprintf("chat.user.*.request.room.*.%s.mute.toggle", siteID) } +// FavoriteToggle returns the concrete subject for the per-user favorite.toggle RPC. +func FavoriteToggle(account, roomID, siteID string) string { + return fmt.Sprintf("chat.user.%s.request.room.%s.%s.favorite.toggle", account, roomID, siteID) +} + +// FavoriteToggleWildcard is the per-site subscription pattern for the favorite.toggle RPC. +func FavoriteToggleWildcard(siteID string) string { + return fmt.Sprintf("chat.user.*.request.room.*.%s.favorite.toggle", siteID) +} + // RoomCreate: client→room-service create subject; siteID is the requester's site. func RoomCreate(account, siteID string) string { return fmt.Sprintf("chat.user.%s.request.room.%s.create", account, siteID) diff --git a/pkg/subject/subject_test.go b/pkg/subject/subject_test.go index a75636ead..5d3c7732b 100644 --- a/pkg/subject/subject_test.go +++ b/pkg/subject/subject_test.go @@ -377,6 +377,30 @@ func TestMuteToggle_ParseUserRoomSubject(t *testing.T) { } } +func TestFavoriteToggle(t *testing.T) { + got := subject.FavoriteToggle("alice", "r1", "site-a") + want := "chat.user.alice.request.room.r1.site-a.favorite.toggle" + if got != want { + t.Errorf("FavoriteToggle: got %q, want %q", got, want) + } +} + +func TestFavoriteToggleWildcard(t *testing.T) { + got := subject.FavoriteToggleWildcard("site-a") + want := "chat.user.*.request.room.*.site-a.favorite.toggle" + if got != want { + t.Errorf("FavoriteToggleWildcard: got %q, want %q", got, want) + } +} + +func TestFavoriteToggle_ParseUserRoomSubject(t *testing.T) { + subj := subject.FavoriteToggle("alice", "r1", "site-a") + account, roomID, ok := subject.ParseUserRoomSubject(subj) + if !ok || account != "alice" || roomID != "r1" { + t.Errorf("ParseUserRoomSubject(%q) = (%q,%q,%v), want (alice,r1,true)", subj, account, roomID, ok) + } +} + func TestParseRoomCreateSubject(t *testing.T) { tests := []struct { name string diff --git a/room-service/handler.go b/room-service/handler.go index 349893f8e..175c5c534 100644 --- a/room-service/handler.go +++ b/room-service/handler.go @@ -125,6 +125,9 @@ func (h *Handler) RegisterCRUD(nc *otelnats.Conn) error { if _, err := nc.QueueSubscribe(subject.MuteToggleWildcard(h.siteID), queue, h.natsMuteToggle); err != nil { return fmt.Errorf("subscribe mute toggle: %w", err) } + if _, err := nc.QueueSubscribe(subject.FavoriteToggleWildcard(h.siteID), queue, h.natsFavoriteToggle); err != nil { + return fmt.Errorf("subscribe favorite toggle: %w", err) + } return nil } @@ -1631,3 +1634,91 @@ func (h *Handler) handleMuteToggle(ctx context.Context, subj string, _ []byte) ( return json.Marshal(model.MuteToggleResponse{Status: "ok", Muted: sub.Muted}) } + +func (h *Handler) natsFavoriteToggle(m otelnats.Msg) { + ctx, err := wrappedCtx(m) + if err != nil { + errnats.Reply(ctx, m.Msg, err) + return + } + resp, err := h.handleFavoriteToggle(ctx, m.Msg.Subject, m.Msg.Data) + if err != nil { + errnats.Reply(ctx, m.Msg, err) + return + } + if err := m.Msg.Respond(resp); err != nil { + slog.Error("failed to respond to favorite toggle", "error", err) + } +} + +func (h *Handler) handleFavoriteToggle(ctx context.Context, subj string, _ []byte) ([]byte, error) { + account, roomID, ok := subject.ParseUserRoomSubject(subj) + if !ok { + return nil, fmt.Errorf("invalid favorite-toggle subject: %s", subj) + } + + if span := trace.SpanFromContext(ctx); span.IsRecording() { + span.SetAttributes( + attribute.String("room.id", roomID), + attribute.String("site.id", h.siteID), + ) + } + + sub, err := h.store.ToggleSubscriptionFavorite(ctx, roomID, account) + if err != nil { + if errors.Is(err, model.ErrSubscriptionNotFound) { + return nil, errNotRoomMember + } + return nil, fmt.Errorf("toggle subscription favorite: %w", err) + } + + now := time.Now().UTC() + + subEvt := model.SubscriptionUpdateEvent{ + UserID: sub.User.ID, + Subscription: *sub, + Action: "favorite_toggled", + Timestamp: now.UnixMilli(), + } + subEvtData, err := json.Marshal(subEvt) + if err != nil { + return nil, fmt.Errorf("marshal subscription update event: %w", err) + } + if err := h.publishCore(ctx, subject.SubscriptionUpdate(account), subEvtData); err != nil { + slog.Error("subscription update publish failed", "error", err, "account", account) + // Non-fatal — the DB write is the source of truth; clients will reconcile on next refetch. + } + + userSiteID, err := h.store.GetUserSiteID(ctx, account) + if err != nil { + return nil, fmt.Errorf("get user siteId: %w", err) + } + if userSiteID != "" && userSiteID != h.siteID { + payload := model.SubscriptionFavoriteToggledEvent{ + Account: account, + RoomID: roomID, + Favorite: sub.Favorite, + Timestamp: now.UnixMilli(), + } + payloadData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal favorite-toggled payload: %w", err) + } + outbox := model.OutboxEvent{ + Type: model.OutboxSubscriptionFavoriteToggled, + SiteID: h.siteID, + DestSiteID: userSiteID, + Payload: payloadData, + Timestamp: now.UnixMilli(), + } + outboxData, err := json.Marshal(outbox) + if err != nil { + return nil, fmt.Errorf("marshal outbox event: %w", err) + } + if err := h.publishToStream(ctx, subject.Outbox(h.siteID, userSiteID, model.OutboxSubscriptionFavoriteToggled), outboxData); err != nil { + return nil, fmt.Errorf("publish favorite-toggled outbox: %w", err) + } + } + + return json.Marshal(model.FavoriteToggleResponse{Status: "ok", Favorite: sub.Favorite}) +} diff --git a/room-service/handler_test.go b/room-service/handler_test.go index b22240d67..f08b18b65 100644 --- a/room-service/handler_test.go +++ b/room-service/handler_test.go @@ -4268,3 +4268,247 @@ func TestPublishCreateRoom_NoProvisioner_Skips(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, published) } + +func TestHandler_FavoriteToggle_Success(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(&model.Subscription{ + ID: "s1", + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, + RoomID: "r1", + SiteID: "site-a", + Favorite: true, + }, nil) + store.EXPECT(). + GetUserSiteID(gomock.Any(), "alice"). + Return("site-a", nil) + + var coreSubjects []string + var coreBodies [][]byte + h := &Handler{ + store: store, + siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { + t.Fatal("publishToStream must not be called for same-site favorite toggle") + return nil + }, + publishCore: func(_ context.Context, subj string, data []byte) error { + coreSubjects = append(coreSubjects, subj) + coreBodies = append(coreBodies, data) + return nil + }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + resp, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.NoError(t, err) + + var got model.FavoriteToggleResponse + require.NoError(t, json.Unmarshal(resp, &got)) + assert.Equal(t, "ok", got.Status) + assert.True(t, got.Favorite) + + require.Len(t, coreSubjects, 1) + assert.Equal(t, subject.SubscriptionUpdate("alice"), coreSubjects[0]) + + var evt model.SubscriptionUpdateEvent + require.NoError(t, json.Unmarshal(coreBodies[0], &evt)) + assert.Equal(t, "favorite_toggled", evt.Action) + assert.True(t, evt.Subscription.Favorite) + assert.Equal(t, "alice", evt.Subscription.User.Account) +} + +func TestHandler_FavoriteToggle_CrossSitePublishesOutbox(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(&model.Subscription{ + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, + RoomID: "r1", + SiteID: "site-a", + Favorite: true, + }, nil) + store.EXPECT(). + GetUserSiteID(gomock.Any(), "alice"). + Return("site-b", nil) + + var streamSubj string + var streamData []byte + h := &Handler{ + store: store, siteID: "site-a", + publishToStream: func(_ context.Context, s string, d []byte) error { + streamSubj = s + streamData = d + return nil + }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.NoError(t, err) + + assert.Equal(t, subject.Outbox("site-a", "site-b", model.OutboxSubscriptionFavoriteToggled), streamSubj) + + var outbox model.OutboxEvent + require.NoError(t, json.Unmarshal(streamData, &outbox)) + assert.Equal(t, model.OutboxSubscriptionFavoriteToggled, outbox.Type) + assert.Equal(t, "site-a", outbox.SiteID) + assert.Equal(t, "site-b", outbox.DestSiteID) + + var payload model.SubscriptionFavoriteToggledEvent + require.NoError(t, json.Unmarshal(outbox.Payload, &payload)) + assert.Equal(t, "alice", payload.Account) + assert.Equal(t, "r1", payload.RoomID) + assert.True(t, payload.Favorite) + assert.NotZero(t, payload.Timestamp) +} + +func TestHandler_FavoriteToggle_NotRoomMember(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(nil, model.ErrSubscriptionNotFound) + + h := &Handler{ + store: store, siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { return nil }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + assert.ErrorIs(t, err, errNotRoomMember) +} + +func TestHandler_FavoriteToggle_InvalidSubject(t *testing.T) { + h := &Handler{ + siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { return nil }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + _, err := h.handleFavoriteToggle(context.Background(), "garbage.subject", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid favorite-toggle subject") +} + +func TestHandler_FavoriteToggle_StoreError(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(nil, fmt.Errorf("db down")) + + h := &Handler{ + store: store, siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { return nil }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + subj := subject.FavoriteToggle("alice", "r1", "site-a") + _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "toggle subscription favorite") +} + +func TestHandler_FavoriteToggle_GetUserSiteIDError(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(&model.Subscription{ + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, RoomID: "r1", + }, nil) + store.EXPECT(). + GetUserSiteID(gomock.Any(), "alice"). + Return("", fmt.Errorf("mongo down")) + + h := &Handler{ + store: store, siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { + t.Fatal("publishToStream must not be called when GetUserSiteID fails") + return nil + }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "get user siteId") +} + +func TestHandler_FavoriteToggle_CrossSiteOutboxPublishFailure(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(&model.Subscription{ + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, + RoomID: "r1", + SiteID: "site-a", + Favorite: true, + }, nil) + store.EXPECT(). + GetUserSiteID(gomock.Any(), "alice"). + Return("site-b", nil) + + h := &Handler{ + store: store, siteID: "site-a", + publishToStream: func(_ context.Context, _ string, _ []byte) error { + return fmt.Errorf("nats unavailable") + }, + publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "publish favorite-toggled outbox") +} + +func TestHandler_FavoriteToggle_CorePublishFailureIsNonFatal(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + + store.EXPECT(). + ToggleSubscriptionFavorite(gomock.Any(), "r1", "alice"). + Return(&model.Subscription{ + User: model.SubscriptionUser{ID: "u1", Account: "alice"}, + RoomID: "r1", + SiteID: "site-a", + Favorite: true, + }, nil) + store.EXPECT(). + GetUserSiteID(gomock.Any(), "alice"). + Return("site-a", nil) + + h := &Handler{ + store: store, siteID: "site-a", + publishCore: func(_ context.Context, _ string, _ []byte) error { + return fmt.Errorf("core nats down") + }, + publishToStream: func(_ context.Context, _ string, _ []byte) error { + t.Fatal("publishToStream must not be called for same-site favorite toggle") + return nil + }, + } + + subj := subject.FavoriteToggle("alice", "r1", "site-a") + resp, err := h.handleFavoriteToggle(context.Background(), subj, nil) + require.NoError(t, err, "publishCore failure must be non-fatal — DB write is the source of truth") + + var got model.FavoriteToggleResponse + require.NoError(t, json.Unmarshal(resp, &got)) + assert.Equal(t, "ok", got.Status) + assert.True(t, got.Favorite) +} diff --git a/room-service/integration_test.go b/room-service/integration_test.go index 372413884..b8befb73f 100644 --- a/room-service/integration_test.go +++ b/room-service/integration_test.go @@ -2039,6 +2039,48 @@ func TestMongoStore_ToggleSubscriptionMute(t *testing.T) { assert.ErrorIs(t, err, model.ErrSubscriptionNotFound) } +func TestMongoStore_ToggleSubscriptionFavorite(t *testing.T) { + db := testutil.MongoDB(t, "room-svc-fav") + store := NewMongoStore(db) + ctx := context.Background() + + // Insert a sub via raw BSON without a "favorite" field at all — proves the + // $ifNull branch handles legacy docs and toggles missing→true on first call. + rawSub := bson.M{ + "_id": idgen.GenerateUUIDv7(), + "u": bson.M{"_id": "u1", "account": "alice", "isBot": false}, + "roomId": "r1", + "roomType": model.RoomTypeChannel, + "siteId": "site-a", + "roles": []model.Role{model.RoleMember}, + "joinedAt": time.Now().UTC(), + "muted": false, + // no "favorite" key on purpose + } + _, err := db.Collection("subscriptions").InsertOne(ctx, rawSub) + require.NoError(t, err) + + got, err := store.ToggleSubscriptionFavorite(ctx, "r1", "alice") + require.NoError(t, err) + require.NotNil(t, got) + assert.True(t, got.Favorite, "first toggle on legacy doc must flip missing→true") + assert.Equal(t, "alice", got.User.Account) + assert.Equal(t, "r1", got.RoomID) + + persisted, err := store.GetSubscription(ctx, "alice", "r1") + require.NoError(t, err) + assert.True(t, persisted.Favorite) + + got, err = store.ToggleSubscriptionFavorite(ctx, "r1", "alice") + require.NoError(t, err) + require.NotNil(t, got) + assert.False(t, got.Favorite, "second toggle must flip true→false") + + gotNil, err := store.ToggleSubscriptionFavorite(ctx, "missing", "alice") + assert.Nil(t, gotNil) + assert.ErrorIs(t, err, model.ErrSubscriptionNotFound) +} + // TestMongoStore_ListRoomMembers_OrgDisplay_DeptFirst_Integration verifies that // when an org member's id matches both a user's deptId and another user's // sectId, the dept branch wins and the combined "name tcName" string is diff --git a/room-service/mock_store_test.go b/room-service/mock_store_test.go index 764fb6337..4002fcb9c 100644 --- a/room-service/mock_store_test.go +++ b/room-service/mock_store_test.go @@ -313,6 +313,21 @@ func (mr *MockRoomStoreMockRecorder) MinSubscriptionLastSeenByRoomID(ctx, roomID return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MinSubscriptionLastSeenByRoomID", reflect.TypeOf((*MockRoomStore)(nil).MinSubscriptionLastSeenByRoomID), ctx, roomID) } +// ToggleSubscriptionFavorite mocks base method. +func (m *MockRoomStore) ToggleSubscriptionFavorite(ctx context.Context, roomID, account string) (*model.Subscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ToggleSubscriptionFavorite", ctx, roomID, account) + ret0, _ := ret[0].(*model.Subscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ToggleSubscriptionFavorite indicates an expected call of ToggleSubscriptionFavorite. +func (mr *MockRoomStoreMockRecorder) ToggleSubscriptionFavorite(ctx, roomID, account any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ToggleSubscriptionFavorite", reflect.TypeOf((*MockRoomStore)(nil).ToggleSubscriptionFavorite), ctx, roomID, account) +} + // ToggleSubscriptionMute mocks base method. func (m *MockRoomStore) ToggleSubscriptionMute(ctx context.Context, roomID, account string) (*model.Subscription, error) { m.ctrl.T.Helper() diff --git a/room-service/store.go b/room-service/store.go index 70fdea7b6..b835df301 100644 --- a/room-service/store.go +++ b/room-service/store.go @@ -87,6 +87,9 @@ type RoomStore interface { // ToggleSubscriptionMute atomically flips muted via a single FindOneAndUpdate. // Returns the post-flip subscription, or model.ErrSubscriptionNotFound (wrapped) when no match. ToggleSubscriptionMute(ctx context.Context, roomID, account string) (*model.Subscription, error) + // ToggleSubscriptionFavorite atomically flips favorite via a single FindOneAndUpdate. + // Returns the post-flip subscription, or model.ErrSubscriptionNotFound (wrapped) when no match. + ToggleSubscriptionFavorite(ctx context.Context, roomID, account string) (*model.Subscription, error) // GetUserSiteID returns the home site of a user looked up by account. // Returns ("", nil) when the user is not found locally; callers treat // that as "skip cross-site outbox". diff --git a/room-service/store_mongo.go b/room-service/store_mongo.go index 1e830a084..78bf74098 100644 --- a/room-service/store_mongo.go +++ b/room-service/store_mongo.go @@ -838,6 +838,28 @@ func (s *MongoStore) ToggleSubscriptionMute(ctx context.Context, roomID, account return &result, nil } +// ToggleSubscriptionFavorite: $ifNull treats absent field as false so legacy docs toggle to true on first call. +func (s *MongoStore) ToggleSubscriptionFavorite(ctx context.Context, roomID, account string) (*model.Subscription, error) { + filter := bson.M{"roomId": roomID, "u.account": account} + update := mongo.Pipeline{ + bson.D{{Key: "$set", Value: bson.M{ + "favorite": bson.M{"$not": bson.A{ + bson.M{"$ifNull": bson.A{"$favorite", false}}, + }}, + }}}, + } + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + + var result model.Subscription + if err := s.subscriptions.FindOneAndUpdate(ctx, filter, update, opts).Decode(&result); err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, fmt.Errorf("toggle favorite for %q in room %q: %w", account, roomID, model.ErrSubscriptionNotFound) + } + return nil, fmt.Errorf("toggle favorite for %q in room %q: %w", account, roomID, err) + } + return &result, nil +} + // GetUserSiteID looks up users.siteId by account. Returns ("", nil) if no // user document exists. func (s *MongoStore) GetUserSiteID(ctx context.Context, account string) (string, error) {