Skip to content

feat: push notifications (server side): devices, a test send, receipts - #38

Merged
ThorSPB merged 8 commits into
mainfrom
feat/13-push
Oct 14, 2026
Merged

ThorSPB merged 8 commits into
mainfrom
feat/13-push

Conversation

@ThorSPB

@ThorSPB ThorSPB commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #13

What

  • Tables:
    • push_devices: id, user_id (FK → users, cascade), token (unique), platform (android|ios), created_at, last_registered_at.
    • push_receipts_pending: ticket_id, device_id (FK → push_devices, cascade), created_at.
    • Migration AddPushDevices, generated on ThorSPB.
  • POST /me/devices { token, platform }:
    • 201 when the token is new, 200 when it was known (refreshes last_registered_at);
    • 400 ProblemDetails unless the token is an Expo token (the value is never echoed), and also when platform is missing, a number, or not one of the two names;
    • 404 without an account;
    • a token registered by another student moves to the caller;
    • at most 10 devices per student: the 11th drops the least recently registered.
  • DELETE /me/devices { token }:
    • 204 for any token; it deletes only the caller's own device, so another student's token changes nothing and can't be probed;
    • 400 ProblemDetails only when the body isn't { token }.
  • POST /me/notifications/test:
    • sends "CampusPulse test notification" to the caller's devices and answers { sent };
    • 429 with Retry-After within 60 s, per student, in memory;
    • only a send that reached Expo uses up the minute: after "no devices" or a 502, the next tap works;
    • 404 without an account;
    • 502 when the push service can't be used at all.
  • Sender:
    • IPushSender with ExpoPushSender: a typed HttpClient on https://exp.host, 15 s timeout;
    • sends in batches of ≤100 and reads receipts in batches of 300;
    • optional Push:Expo:AccessToken, sent as Bearer.
    • A whole request Expo refuses logs its errors[].code (never message or details, which list tokens).
  • Tickets:
    • DeviceNotRegistered deletes the device at once, unless it registered again after the send started;
    • an ok ticket becomes a push_receipts_pending row stamped with the send time;
    • a device deleted mid-send loses only its own receipt.
  • PushReceiptChecker (a BackgroundService, always registered):
    • runs every 15 min and reads receipts for tickets at least 15 min old, in batches of 300;
    • each batch is applied in one transaction: first the dead devices (only those not registered again since the ticket's send; their pending rows go by cascade), then the answered rows;
    • a batch Expo can't answer keeps its rows and doesn't undo the batches already read;
    • rows older than 24 h are dropped unread; with nothing pending, a pass is one query.
  • Strict request bodies, app-wide:
    • JsonStringEnumConverter(CamelCase, allowIntegerValues: false), plus RespectRequiredConstructorParameters;
    • a 400 with no body (a body that couldn't be read) gets a ProblemDetails body from UseStatusCodePages;
    • ThrowOnBadRequest = false in every environment.
  • Logs: counts and error codes only. Codes must be ASCII letters and _ (PUSH_TOO_MANY_EXPERIENCE_IDS passes, anything with a token can't); anything else becomes Unknown.
  • Pi: ^exp\.host:443$ added to deploy/pi/egress-allow. deploy/pi/README.md says installing it is a manual step, and why the proxy is used.
  • README:
    • a Push notifications section, the config row and the Data section;
    • the contract section: strict bodies;
    • the client flow: register on every app launch, after POST /me and on token change; delete on sign-out.

Why

Server side of push (#13): devices can be registered and reached, dead tokens clean themselves up without taking a reinstalled phone with them, and nothing about a student or a token reaches the logs.

Decisions beyond the issue

  • DELETE /me/devices takes the token in the body, not the path. Paths are logged: by our request line, in every line's RequestPath scope, and in nginx's access log. A token in the URL would leak on every sign-out.
  • A malformed DELETE /me/devices body is a 400 (declared in the contract), not a silent 204. Only the token's value is kept unprobeable.
  • Enums are camelCase names ("android" | "ios"), only by name. platform was the first enum in the contract.
  • The token check is anchored with \z, not $ (.NET's $ also accepts a trailing newline), and capped at 200 characters to match the column.
  • Microsoft.EntityFrameworkCore.Relational is pinned to 10.0.13 in Infrastructure. Without it, the Api's direct relational calls hit MSB3277 (two Relational versions), which fails the build under warnings-as-errors.
  • Registration:
    • it locks the student's row (SELECT … FOR UPDATE), so the cap holds and an overlapping DELETE /me gives a 404;
    • it retries once on a unique violation (another student won the race) or a concurrency exception (the row was deleted in between).
  • The Postgres test factory's HttpClients fail by default (NoNetworkHandler), so no test can reach the network.

How I tested it

  • PushTests (Postgres, Expo stubbed):
    • registration:
      • new is 201; again is 200 with a fresh last_registered_at; a token moves between students;
      • the 11th device drops the least recently registered; 14 concurrent registrations leave 10;
      • a concurrent two-student race never fails;
      • deterministic races through EF interceptors:
        • a rival row appears before the insert → the retry ran (200, not 201) and the later student owns the token;
        • the row is deleted before the update → re-registered (201);
        • the account is deleted before the lock → 404, nothing stored;
    • bad input: bad tokens are 400 without the token, and both prefixes are accepted; bodies with an integer platform, no platform, "windows", no token or no JSON are 400 ProblemDetails, nothing stored;
    • delete: own, another's, unknown and malformed tokens are 204; no body and {} are 400 ProblemDetails;
    • test send:
      • the count, the message, no Bearer by default, receipts stored;
      • 429 with Retry-After: 30, then fine again after 61 s; no devices doesn't use up the minute; no account is 404;
      • a DeviceNotRegistered ticket deletes the device; a device registered again during the send survives one;
      • a device deleted mid-send loses only its own receipt;
      • Expo answering 503, timing out, unreachable or not JSON → 502, nothing deleted or stored, and the next tap works;
    • DELETE /me removes devices and receipts.
  • PushReceiptTests:
    • nothing is read before 15 min; ok, DeviceNotRegistered and not-ready receipts are each handled; rows expire after 24 h; nothing pending means no requests;
    • a device registered again after the send survives a late DeviceNotRegistered receipt;
    • a 502, data as an array, or a non-JSON answer → a failed batch, rows and devices kept;
    • two batches where the first is read and the second fails → the first is applied, the second's row kept;
    • the leak test runs everything: the unique-violation race (EF logs the DbUpdateException at Error, so turning on Include Error Detail would fail it), a PUSH_TOO_MANY_EXPERIENCE_IDS refusal whose details list tokens, moves, refusals, a 400 body, DeviceNotRegistered tickets and receipts. No token, oid, user id or email appears in any message, exception or scope value.
  • ExpoPushSenderTests:
    • batches of 100 with tickets in order; error codes kept, odd ones Unknown;
    • a failed batch refuses only its own messages; a mismatched answer is malformed;
    • a real timeout and an unreachable service → RequestFailed; non-JSON → malformed;
    • a refused request (400, or 200 with errors) logs the codes and never the tokens in its details; a refused receipt read names the codes and not the details;
    • receipts are read in batches of 300, missing ones aren't ready; 429, wrong shape, non-JSON, timeout or unreachable → throws.
  • TestNotificationLimitTests: a slot is held a minute, a released slot is free at once, and expired entries are dropped on every call.
  • PushSetupTests: the handler keeps the default proxy, with a 15 s timeout, the base address, and a Bearer only when configured.
  • Other:
    • PerUserDataTests: both walks reach push_devices and push_receipts_pending, all cascade;
    • PushDeviceTests (Domain): token shapes, the length cap, Register.

API contract

  • Added:
    • POST /me/devices (RegisterPushDevice): 201/200 DeviceResponse, 400, 404, 401;
    • DELETE /me/devices (UnregisterPushDevice): body UnregisterDeviceRequest, 204, 400, 401;
    • POST /me/notifications/test (SendTestNotification): 200 TestNotificationResponse, 404, 429, 502, 401.
  • Schemas: RegisterDeviceRequest, UnregisterDeviceRequest, DeviceResponse, TestNotificationResponse, PushPlatform ("android" | "ios").
  • Behaviour on every endpoint: request bodies must have every field and use enum names; anything else is a 400 ProblemDetails.

Checklist

  • tests cover the change
  • a schema change comes with its migration
  • contract changes are listed above

🤖 Generated with Claude Code

ThorSPB and others added 8 commits October 14, 2026 05:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated on ThorSPB (dotnet ef migrations add, dotnet build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or codes, races, limits)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ThorSPB
ThorSPB merged commit 42626ba into main Oct 14, 2026
3 checks passed
@ThorSPB
ThorSPB deleted the feat/13-push branch October 14, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Push notifications: device tokens and sending

1 participant