Push notifications for every trigger, a quieter platform, and solid test coverage across the whole solution

Push notifications for every trigger, a quieter platform, and solid test coverage across the whole solution

push-notificationsonesignalreliabilitycoverageci-cdalpha
Fiction

Push notifications for every trigger, a quieter platform, and solid test coverage across the whole solution

Smartphone with notifications overlaid — a symbol of the new push palette that landed everywhere in the stack this week Photo: Jamie Street on Unsplash

The last two weeks each had one clear focus — tools and admin screens, then security and privacy. This week was more varied. Pushing a single theme to the front would shortchange three others, so I'll tell it along three threads: notifications (from a rudimentary "one thing a day" to a well-rounded palette with fifteen event types for authors and readers plus an anonymous-friendly broadcast), reliability (a Sentry dashboard that's gone quiet, an automatic refresh-and-retry path for expiring access tokens, Functions hosts made solid against pod restarts), and test coverage across the whole solution (with a threshold gate that now accompanies every pull request). On top of that, a handful of smaller refinements that shape how the app feels without landing in a headline.

A well-rounded palette of notifications

Until two weeks ago, OutaStory knew exactly one push notification: "new comment on your story." That was a start, but not an incentive to reopen the app — if the only reminder of the platform's existence is a warning that someone might have written something negative, you start to wonder whether you should have allowed the app at all. This week the palette arrived, and it tries to do two things at once: give authors an honest reason to come back (someone just followed you, someone just favorited your story, you just hit a milestone), and gently remind readers about unfinished books waiting in their library to be picked back up.

Fifteen new event types, built in three layers

The pipeline construction follows a single pattern that now works identically for every event type: a trigger in the domain code (somewhere a controller or a background job writes a record that deserves an event), a NotificationEventMessage on a dedicated Service Bus queue, a NotificationProcessor function that resolves the recipient list, looks up each recipient's per-channel preference (in-app / push / email), localizes the title and body (German + English), builds the deep link, and then serves the respective channel.

The new types were added in this order:

Writing-oriented events (for authors who want to know what's happening with their work):

  • newsletter.weekly — the counterpart to the daily "Story of the Day" newsletter, but as a weekly, personalized edition per recipient.
  • story.chapter_published — fans out to everyone who has favorited the story as soon as a new chapter is published.
  • story.milestone.reads — triggered when a story hits 100, 500, 1,000, 5,000, or 10,000 reading sessions.
  • story.milestone.favorites — same thresholds for favorites.
  • author.milestone.followers — same thresholds for author follows.

Reader-oriented events (for readers who still have something sitting in their library):

  • story.completed — fans out to everyone who has favorited a story as soon as the author marks it "complete." Follows the version chain — whoever saved V1 as a favorite gets the push once V2 wraps up the series.
  • comment.thread_activity — when someone new writes in a comment thread you've already commented in yourself (and aren't the trigger). Kept strictly separate from the already-existing comment.replied notification, because it's a different relationship — "the discussion continues" rather than "someone replied to you."
  • reading.resume_reminder — when you've started a story between 20% and 99% and haven't opened it in seven days. Fitted with a 14-day cooldown per person, so the reminder doesn't turn into an annoying loop.
  • recommendation.weekly — a curated weekly recommendation based on the recommendation model, with a 7-day cooldown per person.

Platform broadcasts (for every device that has accepted the push prompt — anonymous and logged-in alike):

  • platform.broadcast.daily_story — the daily "Story of the Day" pick pushed to the OneSignal "Subscribed Users" audience, which at OneSignal delivers both to devices tied to an Auth0 sub and to anonymous devices. That's the lever that gets a new iPad user, who's a day past install and doesn't have an account yet, offered a story anyway.
  • platform.broadcast.contest_published — when an administrator publishes a contest and checks the "Push to all subscribed devices" checkbox in the publish dialog. The checkbox is off by default — an accidental re-publish after a typo fix shouldn't ping every device a second time.

The separation between broadcast events and per-user events matters: broadcast events go through no recipient resolution, write no in-app row (there's no per-user audience that would see it), write no email (anonymous devices have no address) — they go straight to the OneSignal segment API. That's the only way to reach both anonymous devices and logged-in accounts in a single REST call.

In-app notification center, bell badge, and /notifications routing

Until now there was no central overview — a push notification was a push notification, and then it was gone. As of this week, the NotificationProcessor writes a Notification row plus one NotificationRecipient row per recipient for every per-user trigger. The notification center at /notifications lists them chronologically, grouped by tab ("All," "Comments," "Follows," "Promotions," "System"), and the bell icon in the top bar shows a badge with the unread count. A "Mark all as read" button clears everything in one go.

The notification center used to be a simple <a> element pointing at a content page. The new version is a Razor component with live updates over the existing SignalR connection — if a comment arrives during an active session, the bell fills up immediately, no page reload required. The tab labels were also fixed (PRs from the previous week occasionally showed raw resource keys instead of the localized strings); a bUnit test in the test suite now enforces that condition, so a regression pops up red immediately.

Native push permission on iOS and Android

An embarrassing gap I cleaned up this week: the TestFlight and Android alpha apps never asked for push permission. The OneSignal SDK was initialized, the login link between the Auth0 sub and the push identity worked, but the actual permission prompt was a standalone method on a binder service — one that at some point picked up a comment saying "call this on the first notification-worthy action," and was never called from anywhere. On Android 13 and newer, the manifest declaration for POST_NOTIFICATIONS was also missing, which Android has required since API level 33 — without it the permission prompt simply doesn't fire.

Both gaps are closed now. The prompt fires on the first successful OneSignal Initialize, which happens right after the app window is set up in the MAUI variant. A flag in local storage gates it to once per install — if you tap No, you won't be asked again on every app start. Instead, /settings/notifications now has a second panel alongside the existing browser-push panel: "Push Notifications," with the current OS status and a switch that re-triggers the prompt. If permission was previously denied, OneSignal automatically routes into the system settings, so the person can flip the permission there.

The architecture follows the usual pattern: an INativePushPermission interface in OutaStory.App.Shared for the Razor pages, a MAUI implementation in OutaStory.App that wraps the OneSignal binder, and a no-op implementation in OutaStory.App.Web that returns IsAvailable=false. The same panel doesn't render on web, because the existing browser-push path handles that there; on MAUI it's the only panel the page shows.

Settings page, frequency tester, and cooldowns

With the growing list of event types, the settings page needed a structural overhaul. /settings/notifications now lists every configurable type in a single table with three columns per row (in-app / push / email), defaulting to on for every channel, explicit opt-out per channel. Subscription lifecycle events (premium activated, payment failed, account deletion scheduled) are deliberately not in the list — they're immutably important and go through as so-called "mandatory" events, bypassing the preference-gate check.

The cooldowns for reading.resume_reminder (14 days) and recommendation.weekly (7 days) are held per person in two new columns on the Author table (LastResumeReminderUtc, LastWeeklyRecommendationUtc). A small database migration adds the columns; the scheduled functions stamp the respective column on every successful enqueue action, so the pipeline is guaranteed never to write twice within the window.

The platform got noticeably quieter

If push was the sound of the week, quiet was the other big lever. The Sentry dashboard showed a fair number of recurring entries at the start of the week that weren't real bugs — boot beacons from service startups, EF design-time warnings, Polly retry events, a handful of browser-side manifest 404s that never had a user-visible consequence. Each one on its own is small; together they made the real problems hard to find.

Close-up of a circuit board — a symbol for the processes that don't happen where users can see them Photo: Alexandre Debiève on Unsplash

Sentry filters, breadcrumb categories, and boot beacons under their own logger name

Seven pull requests this week reduced the noise. Most of them work with the existing SentryNoiseFilter class in OutaStory.ServiceDefaults — an allow-list architecture that maps each event category to its own filter rule. Added this week: filters for the SignalR strong-connection container logs (an event class that only reports when the backplane is doing a reconnection — that's normal, not a bug), filters for the Polly resilience-pipeline events (every retry was being counted as a Sentry event, which inflated the "problem" count tenfold without adding a single real one), filters for browser fetch exceptions that typically occur after sleep or tab-switch actions (that's browser behavior, not a server bug), and filters for IDBDatabase-closing messages (the browser cleaning up after itself).

An architectural refinement: the boot beacons for every Functions host and the API are now emitted under the logger name OutaStory.Boot. That lets the Sentry filter drop them categorically — a boot beacon is never a bug signal, it's an "I'm still alive" signal that belongs in our App Insights logs, but not in the triage path.

Browser-side bridge: toast mirror, navigation breadcrumbs, and a few fewer mysteries

An older weak spot got fixed this week: when an API call from the web client got a 5xx error and the API-side Sentry capture worked (which it practically always did), the Sentry event landed in the API project's collection — but not in the web collection. That made the web Sentry project an empty surface where browser exceptions occasionally showed up, but never the context of exactly what the person had been doing when it happened.

A small JavaScript bridge in wwwroot/sentry-bridge.js now hooks into the global toast messages as well as the Blazor navigation manager and writes a breadcrumb per event. Now, when an API call goes wrong, the web Sentry collection has a trail: "Person clicked 'Publish' at 14:32 → API call 500 → toast 'Failed to publish' appeared," and the fix path gets shorter.

Logged in, but silently failing API calls after a long idle period

A subtler thing I had to tackle from personal experience: if you're logged in on the platform and come back to the tab after several hours, the UI correctly says you're logged in — but certain API calls failed silently until you logged out and back in. On /profile/tester, for example, the test-challenge list wouldn't come back.

The cause: Blazor Server circuits, kept alive over the SignalR connection for hours after the initial connection, never go through the cookie auth pipeline a second time. The existing refresh-token hook (OnValidatePrincipal) only fires on new HTTP requests — so never for a tab that's been left open. Once the Auth0 access token expires after roughly 24 hours, every outgoing API call from that circuit goes out with an expired token, the API responds with 401, and the person sees "nothing" on the page.

The fix has three parts. As of this week, the AccessTokenCache stores not just the access token but also the refresh token and the absolute expiry time — that lets WebBearerTokenHandler decide for itself when a token is "about to expire." Within a 60-second window before expiry, the access token gets swapped out proactively, before the API request even goes out. If the API still responds with a 401 (which can happen if the cache entry from before the rollout had no refresh token), the handler makes one attempt at a refresh-and-retry: exchange the refresh token for a fresh access token, update the cookie and the cache, and retry the original request with the new token. A single retry by design — if that also ends in a 401, the refresh chain is genuinely dead and the person sees the standard "please sign in again" path.

A Sentry bridge accompanies every transition point: every proactive refresh, every retry attempt, and every final outcome leaves a breadcrumb in the auth.bearer.* Sentry category. If the rare unrecoverable case occurs (refresh token revoked, audience mismatch, signing-key rotation), the handler surfaces an error with all the relevant tags, so the triage path fits on one screen.

A data-protection keyring that survives pod restarts

A less visible but important repair concerned the social-posting pipeline: the Bluesky daily-posting timer could no longer decrypt its encrypted-at-rest app passwords once the corresponding container replica restarted. The cause was a classic DataProtection anti-pattern: the keyring lived on the container filesystem, which is gone on restart. The fix was a switch to a shared keyring in Azure Blob Storage, shared between the API and the social-poster function. With that, the keyring survives any container replica rebuild, and the daily Bluesky posts can read their app passwords even across a pod restart.

Notification pipeline diagnostics and writing pipeline diagnostics

Two larger PR blocks (#594, #596) laid a diagnostics layer over the existing pipelines — no behavior change, but structured logs at every transition point. If the cover generator returns nothing, the SSML validator trips, the audio synthesizer writes no output, a Service Bus receiver dequeues a message twice — that now shows up in a unified Sentry tag shape, which reduces diagnosing a misbehaving pipeline to a single search expression.

Test coverage across the whole solution, with a gate accompanying every PR

Test coverage until now sat at roughly 2,940 passing unit tests, which sounds like a lot — until you break it down by project and notice coverage isn't even everywhere. The domain and service layers were well covered at over 95%; the UI hosts and Functions workers hovered around 50%. This week added what closes the gap.

Freshly cut keys on a wooden board — a symbol for the threshold table that now hangs off every pull request as of this week Photo: Maxim Hopman on Unsplash

A tier table and a coverage script that understands tier value

Instead of a single threshold for the whole solution, a three-tier table was introduced this week:

  • Tier A — 95%: pure logic and contracts. Domain, Shared, SSML, RevenueShare domain, plus the abstractions packages that contain nothing more than interfaces and data types.
  • Tier B — 85%: data and service layers. EF DbContext + repositories, the API, the email module, the IAP module, the OneSignal push layer, Search, ServiceDefaults.
  • Tier C — 70%: Razor hosts, Functions, and ad adapters. App.Shared, App.Web, the five Functions hosts, the two webhook receivers, the eight ad adapters.

Bootstrappers (AppHost, the MAUI shell, the four initialization workers) are explicitly excluded — they're DI wiring and migration ordering that aren't meaningfully captured by unit tests, but by integration tests and manual smoke checks. New projects default to tier C unless explicitly ranked higher in coverage/thresholds.json.

The Check-Coverage.ps1 script now runs in report mode on every pull request: it runs the whole test suite with Coverlet coverage collection, aggregates the results per project, compares each value against the tier threshold, and writes the results table into the CI logs. The mode is deliberately report (not enforce) while we close the last gap — the Razor-page-heavy OutaStory.App.Shared project still sits at around 21% ahead of its 70% threshold, because most pages need bUnit tests with @inject mocks, and that's weeks of work, not a single PR push.

What moved this week

The solution-wide lift-up effort brought 35 of 36 measured projects into the green. For the web app alone, coverage went from 51.9% to 93.3% — the tests now cover every bearer handler, every middleware, every hosted service, and every endpoint, plus most Razor pages with unit tests that don't need a browser. A handful of legitimate bootstrappers got an [ExcludeFromCodeCoverage] attribute with a clear justification (top-level program class, layout shell, authority initialization), following the pattern already documented in AGENTS.md.

Three wave PRs together added around 450 new tests; each PR description lists the tier-table movement, which makes the code-review experience much clearer.

A contest seed thing that never showed up

A smaller fix that mattered for the testing phase: the seeded example contest ("SAMPLE — The Quiet Hour" with a fictional jury and a 9-month run) never showed up on /admin/contests, even though the seed code looked correct and the listing had no hidden filters. The cause sat in the init-data fingerprint mechanism: once per environment, a hash is written over all embedded seed resources and compared on the next boot — if the hash is unchanged, the whole seed sweep is skipped. In exactly this phase, at least one environment had a fingerprint entry alongside a contest table that should have seen the seed but hadn't (a partial-transaction path, or a manual SQL action that cleared some tables but left the fingerprint standing).

The fix follows the established pattern: a SchemaVersion bump from 9 to 10, which guarantees the hash changes and forces a full re-seed on the next boot action. The seeder itself has been idempotent since day one — upsert by (Year, Slug), jury members added additively, runtime changes preserved. Environments that already have the example contest get a no-op refresh.

While I was at it, the already-planned push broadcast for contest publication went in at the same time: the admin "Publish" dialog now has a checkbox "Push notification to all subscribed devices." Default is off — a re-publish after a typo shouldn't ping anyone a second time. When enabled, a single broadcast goes to the OneSignal "Subscribed Users" audience, with the deep link to the contest landing page.

Smaller refinements that shape the feel

A handful of smaller things that don't fit into a headline but make the day-to-day of the app noticeable:

  • Draft deletion on /my-stories (PR #592): a two-step inline confirmation ("Click to delete" → "Really delete?") right in the story list, instead of a modal. The two-step inline variant is faster when you know what you're doing, and safe enough that a single double-click on the wrong row does no harm.
  • /write split into writing and detail tabs (PR #595): the editor page had everything on one long page — the writing surface, tag management, cover generation, audio settings, publish options. That's now split into two tabs: "Write" (just the actual text) and "Details" (everything else). Switching tabs auto-saves the writing tab, so no manual save action is needed between tabs.
  • Monetag as a separately toggleable overlay (PR #557): the /management/ads page now has a separate switch for Monetag, distinct from the primary ad selection (Google AdSense / Media.net / Sample). That lets Monetag run as an additional overlay without affecting the primary slot — we're testing whether the overlay variant makes a revenue difference or just annoys people.
  • Expanded coming-soon/maintenance tests (PR #538): a specific regression in the App.razor SSR path — under load, a sync-over-async path in the layout selection could tip into a deadlock — was added to the suite with a dedicated load test.
  • AGENTS.md as a single source of truth (PRs #546, #582): the agent-instructions file that feeds several tools with the same information (Claude Code, Codex, Copilot, Cursor) is now one canonical file, instead of three separately maintained copies. A small CI rule mechanically enforces the consistency.

Numbers as they stand

  • Migrations: two new production migrations this week (AddNotificationPreferences for the per-user preference table plus column additions, AddReaderPushCooldowns for the two cooldown columns on Author). Applied automatically on every Aspire boot.
  • Seed content: 16 default notification-preference defaults per new account, the two new newsletter-subscription defaults for the @outastory.com core team, the seeded example contest (now consistently reachable), and a handful of additional test challenges for this week's new features.
  • Pull requests: 64 PRs over 8 days, many of them small reliability and Sentry items. Highlights: #616 + #617 + #618 (push palette with 15 event types), #599 + #596 (notification center and pipeline logging), #619 (native push permission), #620 (contest push), #621 (access-token refresh path), #602 + #603 + #607 + #610 (coverage wave), #588 + #560 (DataProtection keyring), eleven further Sentry-quiet PRs.
  • Unit tests: now around 3,380 passing (up from 2,940 a week ago, roughly 440 new tests). All tiers except App.Shared are above their threshold.
  • Sentry noise reduction: the dashboard now shows a comfortably short list of real bugs, after eight more filter PRs this week and seven PRs with code fixes for bugs that had been hiding behind the noise.

What's next

The push pipeline is broadly done as of this week — the shape is set, new event types are small drop-ins. The next priorities are:

  • App.Shared Razor-page tests — the last red tier entry. This will be spread over several weeks, because every page needs its own bUnit setup with @inject mocks.
  • Trusted Signing approval — the Microsoft KYC process has now been open for three and a half weeks. Once it clears, the unsigned banners on /about/apps disappear.
  • Meta app review for Instagram — the instagram_content_publish permission is the last block on daily posting to Instagram (Facebook and Bluesky already run with the permissions we have).
  • Beta tester onboarding wave — we want to run a broader, but still invite-only, beta phase before the public launch. The tester panel is the anchor point; a new wave of test challenges (for each of this week's new push types, plus the profile push switches, plus the refresh-token path) went in this week.

If something on any of the new screens feels off — and especially if a push notification shows up that you didn't turn on (or the reverse: one that's missing even though the default is "on") — let me know. The thresholds for the triggers are fixed in code, but they're not set in stone; every concrete observation helps tune them to what real test users actually experience.


Comments (0)

No comments yet.


Leave a comment

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.