fix(notifications): always serialize streams so clearing the last subscription saves #25

Merged
whitlocktech merged 1 commits from fix/notifications-empty-subscriptions into main 2026-07-22 16:48:08 +00:00
Member

Problem

Turning off the last notification subscription (going from one opted-in stream to zero) failed with "could not save" and the toggle stuck on. It happened regardless of which stream was last — a clear sign the empty-set case was the trigger, not any particular stream.

Root cause

The backend's PUT /auth/me/notifications/subscriptions validator requires the streams field:

body('streams').isArray()

The app serializes request bodies with kotlinx.serialization, whose default encodeDefaults = false omits any property equal to its default. NotificationSubscriptionsDto.streams defaulted to emptyList(), so an empty set serialized to {} (field dropped) → backend rejected it 400 "Validation failed". Any non-empty set includes streams, so only the final toggle-off broke.

Confirmed against the live API:

Body sent Response
{"streams":["news.post"]} 200
{"streams":[]} 200
{} (what the app sent) 400 Validation failed

Fix

Remove the default from NotificationSubscriptionsDto.streams. kotlinx always serializes a property that has no default, so an empty set now goes out as {"streams":[]}. Chosen because the backend contract is correct (the web client always sends the field) and the app was the party violating it — a one-line DTO change with zero blast radius on other request bodies. The sole call site already passes streams explicitly, and the server always returns the field, so response decoding is unaffected. A comment on the field explains why the default must not be re-added.

Added a regression test asserting the empty DTO serializes to {"streams":[]} using the production Json config (so it fails if the default ever returns).

Verification

  • Unit test NotificationsDtoTest.emptySubscriptionsStillSerializeStreamsField passes; :app:testDebugUnitTest and :app:assembleDebug green (JDK 21, -Pksp.incremental=false).
  • On-device (AVD) against the live UOMysticmoon site: turned two general streams on, then off one at a time. The final toggle (1 → 0) now clears cleanly with the banner "Notifications turned off." — no error, no stuck toggle. The live API confirms the account's subscriptions are [].

Docs note added in a companion docs/ PR (PLAN.md §11 gotcha).


AI-assisted: this change was authored with Claude Code (disclosed per org policy).

🤖 Generated with Claude Code

## Problem Turning off the **last** notification subscription (going from one opted-in stream to zero) failed with **"could not save"** and the toggle stuck on. It happened regardless of which stream was last — a clear sign the empty-set case was the trigger, not any particular stream. ## Root cause The backend's `PUT /auth/me/notifications/subscriptions` validator requires the `streams` field: ```js body('streams').isArray() ``` The app serializes request bodies with kotlinx.serialization, whose default `encodeDefaults = false` **omits any property equal to its default**. `NotificationSubscriptionsDto.streams` defaulted to `emptyList()`, so an empty set serialized to `{}` (field dropped) → backend rejected it **400 "Validation failed"**. Any non-empty set includes `streams`, so only the final toggle-off broke. Confirmed against the live API: | Body sent | Response | |---|---| | `{"streams":["news.post"]}` | `200` | | `{"streams":[]}` | `200` | | `{}` (what the app sent) | `400 Validation failed` | ## Fix Remove the default from `NotificationSubscriptionsDto.streams`. kotlinx always serializes a property that has no default, so an empty set now goes out as `{"streams":[]}`. Chosen because the backend contract is correct (the web client always sends the field) and the app was the party violating it — a one-line DTO change with zero blast radius on other request bodies. The sole call site already passes `streams` explicitly, and the server always returns the field, so response decoding is unaffected. A comment on the field explains why the default must not be re-added. Added a regression test asserting the empty DTO serializes to `{"streams":[]}` using the production `Json` config (so it fails if the default ever returns). ## Verification - Unit test `NotificationsDtoTest.emptySubscriptionsStillSerializeStreamsField` passes; `:app:testDebugUnitTest` and `:app:assembleDebug` green (JDK 21, `-Pksp.incremental=false`). - **On-device (AVD) against the live UOMysticmoon site:** turned two general streams on, then off one at a time. The final toggle (1 → 0) now clears cleanly with the banner **"Notifications turned off."** — no error, no stuck toggle. The live API confirms the account's subscriptions are `[]`. Docs note added in a companion `docs/` PR (PLAN.md §11 gotcha). --- AI-assisted: this change was authored with Claude Code (disclosed per org policy). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-07-22 16:37:00 +00:00
fix(notifications): always serialize streams so clearing the last subscription saves
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m34s
9c52a3dafa
Turning off the final notification subscription (going from one opted-in
stream to zero) failed with "could not save" and the toggle stuck on. The
backend's PUT /auth/me/notifications/subscriptions validator requires the
`streams` field (body('streams').isArray()), but kotlinx.serialization omits a
property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto
defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the
backend rejected it 400 "Validation failed". Any non-empty set included the
field, so only the last toggle-off broke — regardless of which stream it was.

Remove the default from NotificationSubscriptionsDto.streams so kotlinx always
emits the field; an empty set now sends `{"streams":[]}` (200). The one call
site already passes streams explicitly and the server always returns the field,
so response decoding is unaffected. Add a regression test asserting the empty
DTO serializes to `{"streams":[]}` under the production Json config.

Verified on-device (AVD) against the live site and via the live API
(`{}` -> 400, `{"streams":[]}` -> 200).

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-07-22 16:39:59 +00:00
whitlocktech scheduled this pull request to auto merge when all checks succeed 2026-07-22 16:40:03 +00:00
whitlocktech merged commit a6446b04d8 into main 2026-07-22 16:48:08 +00:00
whitlocktech deleted branch fix/notifications-empty-subscriptions 2026-07-22 16:48:09 +00:00
Sign in to join this conversation.
No description provided.