Ad integration, a full-fledged support center, and three new admin tools

Ad integration, a full-fledged support center, and three new admin tools

werbungrevenue-sharesupportadminauth0kategorienalpha
Fiction

Ad integration, a full-fledged support center, and three new admin tools

Large control center with many screens, neutral lighting Photo: Unsplash

After last week's maturity wave, this week came with a clear plan: the platform had a few spots where the UI already existed but the engine room behind it didn't yet. Three spots, to be precise — the ad impressions meant to feed the revenue-share system, a proper support center in place of the previous mini help page, and an admin area that does what an admin area should: manage users, maintain categories, edit content. Plus the second version of the revenue-share document, now in sync with what's actually built.

Advertising: eight SDK adapters, one shared capture pipeline

The revenue-share logic itself — the 55/45 split, the premium allocation based on reading time and completions, the monthly reconciliation — has been complete since the phase-10 wave in early May. What was still missing was the bridge from the actual ad network to the capture endpoint. The ad renders; the ad network calls its OnPaidEvent/onLoggingImpression/OnImpressionDataReady callback; but until now, those callbacks never landed in the database.

This week added eight SDK adapters, one per ad surface, all built on the same interface:

  • Google AdMob (Mobile)OnPaidEvent delivers an AdValue with valueMicros + currencyCode per impression. The adapter cleanly maps EUR-native and non-EUR impressions to the wire format; for USD, IEcbRateProvider handles the FX conversion.
  • Google AdSense (Web) — AdSense doesn't offer a callback. Instead, the <ins class="adsbygoogle"> element mutates its data-ad-status attribute to "filled" once the loader actually populates the ad. A single page-wide MutationObserver watches for that transition and calls back into .NET via JSInterop. No revenue or currency comes with each impression (AdSense only reveals that server-side via the Reporting API); on the platform side, the cohort eCPM projection takes over from there.
  • Meta Audience Network (Mobile)onLoggingImpression with no revenue data. Same cohort eCPM fallback as AdSense.
  • Unity Ads (Mobile)OnImpressionDataReady with eCPM in USD ×1000. The adapter normalizes to micros, the server converts to EUR.
  • VAST Audio (Web + Mobile) — not the start of audio playback (that's server-to-ad-network telemetry via the VAST <Impression> URLs), but our own platform telemetry, separate from that. Triggered when the <audio> element actually reports playing — not before.
  • Sample.Web / Sample.Mobile / Sample.Audio — three stub adapters for local development. Carries the network label "Sample" and a {"sample":true} marker in the raw payload, so an operator can cleanly filter out locally generated rows before going live.

All eight adapters sit on one shared interface: IRevenueShareImpressionReporter with a single ReportAsync(ImpressionPayload) method. Each adapter turns its network-specific callback into an ImpressionPayload and passes it along; the reporter handles the HTTP POST to /api/revenueshare/impressions. Three failure semantics are hard-wired: a rejected HTTP 500, a transport error, and a cancellation are all swallowed the same way — the ad has already rendered, losing a single capture row is recoverable via the monthly reconciliation, and throwing back into the SDK callback chain would break the ad pipeline on the consumer side.

On the configuration side, each of the four real networks got a dedicated secrets tool: tools/admob-keys/, tools/adsense-keys/, tools/meta-audience-keys/, tools/unity-ads-keys/, tools/vast-audio-keys/. Each script validates the format of the expected IDs (AdMob has its own schema, AdSense another, Meta yet another), aborts on placeholder TBDs, and writes in this order: AppHost user secrets → Key Vault → GitHub Actions secrets. Same pattern as the other vendor tools (Stripe, SendGrid, Sentry, BZSt) — whoever knows the one script can use any of the others.

That doesn't mean ads go live automatically. What's now running is the wiring: once an ad deal with one of the networks is in place, you enter the IDs, restart the container, and the first impressions land in RevenueShareImpressions. The cohort eCPM table, the FX conversion, the monthly reconciliation against the network report, and the 55/45 split in the ledger have been production-ready since early May.

Revenue-share document: version 2

At the same time, the assets/ads/revenue-share.md document was bumped to version 2 — English and German in parallel, both PDFs regenerated. No clause changes in substance: the 55/45 split, the 80/20 premium hybrid, the €25 threshold, the 60-day clawback window, the payout date on the 5th of the month, and the 30-day notice period for material changes are all unchanged.

What did change is the status block plus a series of clarifications that align the document with the actual code:

  • Front-matter status moved from "Draft for review" to "Implementation complete; pending: legal sign-off + BZSt certificate + Stripe Tax Forms authorization."
  • §1: the list of companion documents expanded to include the implementation plan, runbook, deferred plan, and SDK adapter PRs.
  • §13.3.4: the DPI client documented as implemented, dry-run locked until the BZSt transmitter certificate arrives.
  • §13.5.1: the phase-10.6 pipeline for US 1099-K forms documented, locked until Stripe Tax Forms authorization.
  • §14.0 + new §14.9 + §14.10: nine supporting entities acknowledged (AdImpressionFlagged, LedgerAdjustmentProposal, PlatformBalanceEntry, PremiumPeriod, SubscriberPeriodTotals, AnnualTaxFiling, UsTaxFormRecord, AuthorPayoutAccount, AuthorDispute), with the reasoning for why they sit outside the append-only ledger.
  • §16.3.4: a plausibility limit added for the Sample development network; the RevenueShareCaptureOptions configuration key anchored.

§22 (version history) carries the v2.0 line with the full delta description. The open legal questions from §20 remain open — those aren't implementation gaps, but interpretation questions only a lawyer can resolve.

Support center: help articles, FAQ, and tickets

Person with a headset working intently at a desk Photo: Unsplash

Before this week, the help page in OutaStory was a single screen at /profile/help with a few static "Contact via email / phone / live chat / community" buttons. Phone, live chat, and community don't actually exist on the platform — and showing that suggests support channels we don't offer. This week that was replaced with a full-fledged support center.

The new system has three parts:

Help articles and FAQ

/help now runs a bilingual (EN/DE) help center with a search bar, a category overview, and a curated article list on the landing page. Ten help categories cover the main topics: getting started, reading & listening, writing & publishing, premium, account & profile, audio narration, accessibility, family & parental controls, author compensation, technical issues.

Inside: 11 help articles, 4 of which are marked "featured" for the landing page (welcome tour, reading mode, writing your first story, premium overview). Each article is Markdown-formatted; rendering goes through a Markdig-to-HTML pipeline with HtmlSanitizer as a security backstop and a post-processor that adds target="_blank" rel="noopener noreferrer" to every anchor — links shouldn't unintentionally leave the app context.

/help/faq runs an accordion layout showing the FAQ categories with their entries. 33 FAQ entries across 5 categories (account & billing, reading & content, writing & publishing, family & safety, technical) — all with a typical support-tier question cadence: "How do I sign up?", "What does Premium cost?", "Can I read offline?", "Who can publish on OutaStory?", "Are AI-generated stories allowed?".

The same Markdown-to-HTML path applies to FAQ answers as to article bodies. The German translations live right on the entry — a Question plus a QuestionDe, an AnswerMarkdown plus an AnswerMarkdownDe. That avoids the usual fallback logic in the database and makes the German variant just as first-class as the English one.

Ticket system

At /help/contact, users can open support tickets — anonymously with an email address plus name, or logged in directly from their account. Six topic categories (account & profile, premium & billing, stories & reading, technical, abuse / safety, other); the backend rejects unknown topics so the moderator queue can't balloon indefinitely.

Every ticket gets a 12-character reference in the format OS-XXXX-YYYY, generated on creation — this reference appears in the confirmation email and is the unique identifier across all channels. Ticket status moves through four states: awaiting-staff, awaiting-user, resolved, closed. Replies flip the status automatically — customer replies → awaiting-staff, moderator replies → awaiting-user. Closed tickets are read-only on both sides.

Logged-in users see their tickets at /profile/tickets and can attach replies or read the history there. The ticket detail view shows right-aligned user bubbles and left-aligned staff bubbles, each rendered with Markdown content — visually identical to a chat log.

Moderator queue

At /moderation/tickets (gated with <ModerationGate> for moderator/admin/developer roles), the moderator queue runs. Filter buttons sort by status — the default view is "needs staff" (awaiting-staff + awaiting-user); explicitly switchable to resolved / closed / all. The awaiting-staff column sorts oldest-first, to keep the oldest pressure point in view; every other bucket sorts newest-first.

On the detail page, a moderator can change the status (resolve, close, reopen), set a priority, and write internal notes — replies visible only to the team. That's the typical "I checked the KYC, all good" handoff mechanism between moderators, without the ticket opener seeing operational details.

Admin editing

At /admin/help-articles, /admin/help-categories, /admin/faqs, /admin/faq-categories (admin/developer-gated), there are four CRUD screens for help and FAQ content. The article editor has two fields side by side, one for English, one for German — admins write both languages in one pass instead of switching between tabs. Categories can be deleted, but are blocked as long as articles or FAQ entries live in them — the server returns a typed 409 with a clear message.

Admin area: user management

With the growing tester list, admin tasks have changed: less "approve a story for review" and more "this tester's email is changing," "this person needs the moderator hat," "turn off newsletter delivery for this email address." Until now that was a mix of the Auth0 dashboard, direct database access, and "hey Thimo, could you" — this week that got its own admin screen.

/admin/users (admin/developer-gated) runs user management. A search bar at the top, paginated below, status pills for "active / blocked / unverified," roles shown as small chips, last login + login count per row. Clicking a row opens the detail page with five sections:

  • Profile: edit the Auth0 name + email, plus the bio from the local Author record, if the person has already logged in once.
  • Password: a button that triggers the standard Auth0 password-reset email flow. The admin never sees a password and never sets one — the email with the one-time link goes straight to the user.
  • Roles: a checkbox grid with the live role catalog from Auth0 (Developer, Admin, Moderator, Writer, and whatever else is set up in the tenant). Saving sets the selection as the complete role list; the backend diffs it against the current state and calls minimal Assign + Delete operations.
  • Newsletter: toggles for daily and weekly delivery plus the locale. Writes directly to the NewsletterSubscription table.
  • Block / Unblock: a separate, red-bordered card with a mandatory reason field. Blocking sets the Auth0-native blocked flag (cleanly prevents sign-in and survives token refreshes), mirrors it onto Author.IsProfileHidden to keep the existing moderation flow consistent, and writes an AuthorBlockEvent record with the action + admin actor (Auth0 sub + display name as a snapshot at write time) + the typed reason. The history — every block and unblock event with timestamp, actor, and reason — is visible right below the form.

A deliberate architecture decision: the Auth0 blocked flag is the canonical source of truth; the AuthorBlockEvent table is OutaStory's own audit trail, since Auth0's activity log carries neither the reason nor the human actor. In case of conflict between the two, Auth0 wins — the server checks on the next webhook sync.

Admin area: story categories

The platform currently has 302 categories across four levels — from the top level (e.g., "Kids," "Romance," "Fan Fiction") down to leaf nodes like "Kids → Ages 6–7 — Beginning Readers → Animal Stories." Until now, maintaining categories meant editing the YAML seed plus a container restart. With real tester requests coming in ("a subcategory for historical romance is missing"), that stopped being workable.

Full library shelves with books sorted by topic Photo: Unsplash

/admin/categories (admin/developer-gated) now runs a two-column editing interface:

  • Left side: an indented tree with all categories, sorted by display order and name. Each node shows the name, a revenue chip with the number of stories in that category, and a "+" button. Clicking a node loads it into the editor; clicking the "+" opens the editor for a new subcategory under that node.
  • Right side: an editor with slug, EN/DE name, EN/DE description, parent category (dropdown with breadcrumb paths), display order, and an optional minimum age — that last field is the lever for parental controls: if a category carries a pinned minimum age, every story in it must meet that age.

Deletion is only allowed when the category is empty — no stories directly in it, no subcategories beneath it. That's already enforced at the database level by the Restrict semantics of both foreign keys, but the server does a pre-check and returns a typed 409 with a clear reason instead of letting a generic DbUpdateException surface. On the UI side, the delete button is disabled accordingly, with a tooltip explaining the exact reason: "This category contains stories" or "This category has subcategories."

A second protection layer: re-parenting a category under its own descendant is blocked both on the client (the dropdown hides the options) and on the server (an ancestor walk) — otherwise an admin could accidentally close a loop in the tree. With only 4 levels, the walk depth is trivial; the logic exists anyway, because it would otherwise quietly get lost if the tree limit is expanded later.

Localization across the stack

A smaller wave running through all three new areas: every new sidebar label, every admin card caption, and every profile menu entry lives in the three LayoutResource / DeveloperResource / ProfileResource resx variants (neutral, en-US, de-DE). That was an initial oversight — the first versions of the cards shipped with hardcoded English — and got fixed right away. The sidebar now shows a "Help / Help Center" entry in the user's language; the /admin and /moderation overviews show their new cards in the right language.

The help article and FAQ entry content itself is also bilingual (EN + DE) — that's data-model localization, not resx localization. We keep both languages directly on the entry, with a Title and a TitleDe field, a BodyMarkdown and a BodyMarkdownDe. The German variant is a full sibling of the English one, not an auto-translation fallback.

Numbers as they stand

  • Migration status: two new production migrations this week (SupportCenter with the six support entities and AdminUserBlockEvents with the AuthorBlockEvent table); both applied automatically on every Aspire boot.
  • Seed content: 10 help categories, 11 help articles, 5 FAQ categories, 33 FAQ entries — all with German translation. Re-seeded on boot in every environment; existing content is updated by slug, not duplicated.
  • New pull requests: PR #255–#262 for the eight SDK adapters (stacked — adapters 2–8 depended on the foundation PR), PR #263 for the v2 revenue-share document update with both PDFs, PR #269 for the support center, PR #270 for user management, PR #271 for story category management. Roughly 75 commits total.
  • Unit tests: now around 2,580 passing (2,490 at the start of the week; around 90 new tests, 42 of them for the ad adapters — each adapter has its own forwarding test block).

What's next

The tooling wave is complete now, and the next focus shifts back toward user-visible work. On the list for next week:

  • Anonymous ticket tracking via email magic link: the data model already has the (GuestEmail, Reference) index; what's missing is sending the confirmation link and the ticket-detail endpoint that validates the token.
  • Email notifications for ticket events — opened, new reply, resolved. The IEmailService wiring is in place; what's missing are the SendGrid templates per event type.
  • Auto-closing inactive resolved tickets after 30 days — the repository method exists; what's missing is the BackgroundService that calls it daily.
  • Localization of the detail-page text (breadcrumbs, button labels, section headings) for the new help and admin areas. The sidebar and card labels are localized as of this week; the in-page strings are next.

Until then: if something on any of the new screens feels off, let me know. The small things are often the ones that shape how the app feels.


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.