Autoren folgen, anonym weiterlesen: Bindung vor dem ersten Login
Foto: Unsplash
Die letzten zwei Features vor dem internen Alpha-Start hatten dasselbe Ziel: Leser sollen sich an OutaStory binden können, bevor sie einen Account angelegt haben. Niemand legt einen Account an, nur um zu gucken. Die Entscheidung fällt, wenn der Leser die zweite Geschichte anfangen will — und da müssen wir das Lesezeichen und den Lieblingsautor bereits gespeichert haben. Diese Woche zwei Features, die auf den ersten Blick harmlos klingen, aber im Unterbau überraschend viel Arbeit bedeuten.
Autoren folgen — end-to-end
Auf jeder Autorenseite gibt es jetzt einen Folgen-Button. Klick, Zahl steigt, fertig. Zumindest aus Sicht des Lesers. Darunter läuft eine neue Entität AuthorFollow, eine EF-Migration, eine eindeutige Index-Constraint auf (FollowerUserId, FollowedAuthorId), und ein denormalisierter Zähler Author.FollowerCount, der bei jedem Folgen oder Entfolgen synchron gehalten wird.
Der Grund für den Zähler am Author-Row: auf einer Autorenseite mit hundert Followern würden wir sonst bei jedem Seitenaufruf eine COUNT(*)-Abfrage über die gesamte Follow-Tabelle fahren. Denormalisieren spart die Abfrage. Die Invariante wird in der Repository-Schicht erzwungen: wer FollowAsync aufruft, bekommt den Zähler inkrementiert; wer UnfollowAsync aufruft, dekrementiert. Fehlermodi sind idempotent — zweimal folgen zählt nicht doppelt, zweimal entfolgen geht auch dann, wenn man gar nie gefolgt hat.
Self-Follow wird am Repository-Layer abgelehnt. Der Autor kann nicht sich selbst folgen, das ist absichtlich. Der Button auf der Autorenseite wird ausgeblendet, wenn AuthState.CurrentUser?.Sub dem Autor gehört.
Follower-Graph im Seed: ~22 Follower pro Autor
Eine leere Autorenseite mit „0 Follower" ist ein schlechter erster Eindruck. Wir haben einen deterministischen Follower-Graphen in die Seed-Pipeline eingebaut: für jedes Autor/User-Paar wird ein SHA-256-Hash aus (FollowerUserId | AuthorSlug) berechnet. Fällt der Hash in das erste 40-Prozent-Bucket, wird eine Follow-Beziehung angelegt. Ergibt ~22 Follower pro Autor über den gesamten Seed-Pool.
Der Hash ist deterministisch, also produziert jede Neuinitialisierung denselben Graphen. Das erleichtert Tests — die Zahlen sind reproduzierbar. E2E-Testuser sind gezielt ausgeschlossen, damit unsere Playwright-Asserts nicht davon abhängen, ob Auth0:SeedE2eTestUsers gesetzt ist.
AverageRating pro Autor — als echte Aggregation
Der Autoren-DTO trägt jetzt ein neues Feld AverageRating. Eine ungewichtete Durchschnittsbewertung über alle StoryRatings aller Geschichten dieses Autors, berechnet serverseitig in einer einzelnen GROUP BY-Projektion. Keine zweiten Round-Trips, keine N+1-Falle.
Das klingt trivial, hat aber einen Seiteneffekt: wir haben jetzt echte, aggregierte Bewertungen ab dem ersten Aufruf. Der alte Wert war ein Placeholder. Die Autorenseite spürt das sofort.
Anonyme Lesefortschritte — im localStorage
Der zweite Feature-Brocken der Woche: Lesefortschritte für nicht angemeldete Leser. Bisher hieß „Lesezeichen" implizit „Login zuerst". Das verträgt sich nicht mit der Grundentscheidung, dass Lesen auf OutaStory immer frei und ohne Account möglich ist. Wer zweimal auf „Weiter" klickt und dann die Seite zumacht, soll beim nächsten Besuch die „Weiterlesen"-Kachel auf der Startseite sehen — egal ob eingeloggt oder nicht.
Foto: Unsplash
Die Umsetzung ist ein IAnonymousReadingProgressStore über JS-Interop auf localStorage. Ein einzelner JSON-Blob unter dem Schlüssel os-anon-progress, gelesen und geschrieben über dieselben Pfade wie die serverseitige Fortschritts-Variante. Wichtige Invarianten bleiben erhalten:
- Forward-only: Ein „Seite 1"-Event nach bereits gelesener Seite 5 setzt nichts zurück.
- Latched completion: Einmal fertig gelesen, bleibt fertig.
- Prerender-sicher: Wenn das JS-Interop während des Blazor-Server-Prerenders nicht verfügbar ist (es wirft
InvalidOperationException), fällt die Schicht ruhig auf „leer" zurück, ohne einen Circuit abzubrechen.
Merge bei der ersten Anmeldung
Der interessante Teil ist, was passiert, wenn der anonyme Leser sich doch entscheidet, einen Account anzulegen. Dann muss der localStorage-Graph in den Auth0-gebundenen Server-Graph fließen — ohne Datenverlust, ohne doppelte Einträge.
Das läuft über einen neuen Endpunkt POST /api/users/me/reading-progress/merge plus eine Repository-Methode UpsertManyAsync, die entryweise dieselben Forward-only- und Latch-Regeln anwendet wie eine Live-Seitenwechsel. Der Client-Facade IReadingProgressService entscheidet pro Aufruf, ob er den API- oder den localStorage-Pfad nimmt; auf der Sign-in-Transition postet er einmalig den kompletten lokalen Graph an den Merge-Endpoint und löscht localStorage danach.
Gegen parallele Sign-in-Events (Auth-Cookie-Refresh löst OnAuthChanged manchmal zweimal schnell hintereinander aus) schützt ein SemaphoreSlim plus ein _mergeAttempted-Flag — der Merge läuft maximal einmal pro Circuit pro Sign-in.
Die Startseite sieht davon nichts
Der Trick, den wir am meisten mögen: die Startseite hat keinen Unterschied zwischen angemeldeten und anonymen Lesern. Dieselbe „Continue Reading"-Reihe, dieselben „Read Again"-Kacheln, dieselbe Chip-Logik (bernsteinfarben für laufend, grün für fertig). Der Service entscheidet hinter dem IReadingProgressService-Interface, welche Datenquelle er anzapft. Die Razor-Komponente weiß davon nichts — sie iteriert über eine Liste von StoryWithProgressDto-Rows und rendert Karten.
Das ist die Art von Architektur-Entscheidung, die sich erst beim zweiten Feature auszahlt. Der Reader meldet Fortschritte einfach an ReadingProgress.ReportAsync(storyId, percent, isCompleted). Ob das dann in localStorage oder in SQL Server landet, ist seine Angelegenheit nicht.
Kleine SEO-Nacharbeiten
Nebenher haben wir ein paar kleinere SEO-Punkte von der Liste erledigt, die vor dem Alpha-Start noch dranhängen sollten:
- Das Standard-OG-Bild (das Fallback-Bild für Seiten ohne eigenes Cover) ist jetzt 1200×630 — die Facebook-empfohlene Landscape-Größe — statt des alten 16:9-Behelfs.
- Kategorie-URLs mit
?sort=…oder?filter=…setzen jetzt<meta name="robots" content="noindex,follow">. Die kanonische Taxonomie-URL bleibt die einzige indexierte Variante; Google und Bing folgen den Links weiter, duplizieren aber keine Ranking-Signale über sieben Sort-Varianten derselben Seite. - Ein paar Scaffolding-Komponenten aus dem Template-Start (
MainLayout,NavMenu) sind rausgeflogen. Die benutzen wir nicht mehr, der Router-Layout istAppLayout.
Was kommt als nächstes
Nach drei Wochen Aufbau und einer Woche Politur stehen wir kurz vor dem Alpha-Check-in. Die Android-App wartet auf die Closed-Alpha-Freigabe bei Google, iOS ist noch in Prüfung. Sobald beide durch sind, gehen die Einladungen an die ersten externen Tester raus. Und dann kommt die nächste Schicht Arbeit: die Dinge, die wir im Alpha-Feedback lernen werden, die wir jetzt noch nicht wissen.
