managarten/docs/plans/feedback-rewards-and-identity.md
Till JS 246c94374f test(feedback): pixel-avatar + redact privacy-boundary; mark plan SHIPPED
Tests:
- packages/feedback/src/avatar.test.ts — 10 unit tests (determinism,
  mirror-symmetry, color contrast, padding-resilience, pseudonym-
  integration, density-sanity).
- services/mana-analytics/src/services/feedback-redact.test.ts —
  9 privacy-boundary tests verifying:
    * anonymous path NEVER includes realName, even when author opted in
    * auth path NEVER includes realName when author opted OUT
    * realName only when (opted-in AND auth-path) — both gates required
    * userId / deviceInfo / voteCount stripped from output

Plan-Doc:
- docs/plans/feedback-rewards-and-identity.md status → shipped (3.A,
  3.B, 3.C, 3.F live; 3.D, 3.E open) mit Commit-Hashes.

Service-Layer minor: REWARD-const + redact als __TEST__-Export
publik gemacht (nur fürs Testen, kein Verhaltensänderung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:11:17 +02:00

18 KiB
Raw Permalink Blame History

status owner created parent
shipped (3.A, 3.B, 3.C, 3.F live 2026-04-27 — 3.D, 3.E open) till 2026-04-27 docs/plans/feedback-hub-public.md

Feedback-Hub Phase 3 — Rewards, Identity & Loop-Closure

Macht den Public-Community-Hub zu einem lebendigen System: User kriegen sofort etwas zurück (Credits), sehen ihre Wünsche durchs System wandern (Status-Notify), entwickeln eine Identität ohne Klarnamen-Zwang (Eulen + opt-in Real-Name + Pixel-Avatar), und werden im richtigen Moment getriggert.

Pre-launch. Wir brechen alles was nicht passt — keine Backward-Compat-Shims, keine Legacy-Reste, kein "vorerst". Saubere Datenmodelle, klare Service-Verträge, präzise Naming.


Leitprinzipien

  1. Reziprozität sofort. Jedes Submit kriegt sofort +5 Credits, jedes geshippte Feature kriegt +500 Credits beim Originalwunsch-Eulen. Keine Verzögerung, kein "Founder approves first" — der User soll den Loop spüren beim ersten Submit.
  2. Loop-Closure ist Pflicht. Wer reagiert hat, muss erfahren wenn sein Item shipped. Stiller Erfolg ist kein Erfolg.
  3. Identität ohne Klarname-Zwang. Wachsame Eule #4528 ist der Default. User können freiwillig ihren Klarnamen mit-anzeigen, aber die Public-Mirror-Page bleibt immer anonym für SEO + Privacy.
  4. Karma ist visible. Eine Eule mit 200 Karma wirkt anders als eine #1. Reputation entsteht ohne Identität.
  5. Anti-Abuse passiv. Rate-Limits + Founder-Whitelist + LLM-Spam- Filter laufen serverseitig, nie als UI-Friktion.
  6. Kein Legacy. vote/unvote/toggleVote-Shims im Package gehören weg. voteCount-Spalte gehört weg. Alte FeedbackPage-Markup-Stellen die nichts mit Reactions können gehören weg.

Phase 3.A — Direkte Belohnung (1 Tag)

Der wichtigste Schritt. Macht den Loop spürbar.

3.A.1 — /internal/credits/grant in mana-credits

Neuer Endpoint, weil refund semantisch falsch wäre für "User kriegt Credits geschenkt für Mitwirkung":

POST /api/v1/internal/credits/grant
X-Service-Key: <service-key>
Body: {
  userId: string;
  amount: number;
  reason: 'feedback_submit' | 'feedback_shipped' | 'feedback_reaction_match';
  referenceId: string;  // idempotency-key
  description?: string;
}
Response: {
  ok: true;
  newBalance: number;
  alreadyGranted?: true;  // when referenceId already exists
}

Idempotency: referenceId wird in credits.transactions.metadata.referenceId gespeichert. Vor dem Grant: SELECT ... WHERE metadata->>'referenceId' = $1 AND type = 'grant' — wenn schon da, return alreadyGranted: true.

Schema-Erweiterung: neuer transaction-type 'grant' (nicht 'refund').

3.A.2 — mana-analytics ruft auf bei createFeedback

In services/mana-analytics/src/services/feedback.ts direkt nach dem Insert:

const [feedback] = await this.db.insert(...).returning();

// Quality-Gate: nur wenn echte Substanz drin ist
if (feedback.feedbackText.trim().length >= 20) {
  void grantCreditsForSubmit(feedback);  // fail-soft fire-and-forget
}

return feedback;

async function grantCreditsForSubmit(feedback) {
  // Founder-Whitelist (würde sich selbst beschenken)
  if (config.founderUserIds.includes(feedback.userId)) return;
  // Rate-Limit: max 10 grants/User/24h
  if (await hitGrantRateLimit(feedback.userId)) return;
  
  await fetch(`${config.creditsUrl}/api/v1/internal/credits/grant`, {
    method: 'POST',
    headers: { 'X-Service-Key': config.serviceKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      userId: feedback.userId,
      amount: 5,
      reason: 'feedback_submit',
      referenceId: feedback.id,
      description: `Danke für dein Feedback (${feedback.category})`,
    }),
  });
}

Founder-Whitelist als config: neuer env FEEDBACK_FOUNDER_USER_IDS (comma-separated). Lokal default leer, prod <till's user-id>.

Rate-Limit: feedback_grant_log mini-table mit (userId, grantedAt). Cleanup: nightly DELETE WHERE grantedAt < now() - 7 days.

3.A.3 — adminUpdate triggert Ship-Bonus

Wenn Admin Status auf 'completed' setzt (frischer Übergang):

async adminUpdate(feedbackId, patch) {
  const [old] = await this.db.select().from(userFeedback).where(eq(...));
  const [updated] = await this.db.update().set({...}).returning();
  
  // Ship-Trigger: nur beim Übergang, nicht bei Re-Saves auf 'completed'
  if (old.status !== 'completed' && updated.status === 'completed') {
    void grantShipBonus(updated);
  }
  return updated;
}

async function grantShipBonus(feedback) {
  // Originalwunsch-Eule: 500 Credits + Push-Notify
  void fetch(`${creditsUrl}/grant`, { ...500 credits, referenceId: `${id}_shipped`... });
  
  // Reagierer-Bonus: alle die 👍🚀 geklickt haben → +25 Credits
  const reactioners = await getReactionersWithEmojis(feedback.id, ['👍', '🚀']);
  for (const userId of reactioners) {
    void fetch(`${creditsUrl}/grant`, {
      userId, amount: 25, reason: 'feedback_reaction_match',
      referenceId: `${feedback.id}_reaction_${userId}`,
    });
  }
}

Push-Notify an den Original-User: nutzt mana-notify oder direkt in-Mana toast (Phase B). Fail-soft.

3.A.4 — UI-Feedback "+5 Credits" beim Submit

FeedbackQuickModal zeigt nach Submit:

🎉 Danke!
Sichtbar als Wachsame Eule #4528
+5 Mana Credits

Statt nur "Danke!". Sofortige Belohnung erhöht Wahrscheinlichkeit für Folge-Submits drastisch (klassischer variable-Reward-Loop).

Onboarding-Wish (Step 4) auch: Confirm-Step zeigt "+5 Mana Credits" neben dem Pseudonym.


Phase 3.B — Loop-Closure (1 Tag)

User sieht/spürt was mit seinem Wisch passiert.

3.B.1 — Status-Notify bei Status-Change

Wenn ein Wisch von submittedplannedin_progresscompleted wandert: Push-Notification an

  • den Original-Author
  • alle die 👍 oder 🚀 reagiert haben

Server-side via mana-notify (oder in-Mana via mana-sync activity-stream).

Beispiel-Texte:

  • submittedplanned: "Geplant: dein Wunsch {title} ist auf der Roadmap"
  • plannedin_progress: "Wir bauen {title} gerade"
  • in_progresscompleted: "🎉 {title} ist live! +500 Mana"

3.B.2 — /profile/my-wishes Persönliche Roadmap

Eingeloggter User sieht eine Liste aller eigenen Wishes mit live Status-Badge + Reaction-Count. Plus "Items, auf die ich reagiert habe" unten dran. Reactivate user even when they didn't post recently.

Endpoint existiert schon (getMyFeedback), nur eine View bauen.

3.B.3 — Monthly-Digest E-Mail

Anfang jedes Monats: "April-Digest — diese 7 Wünsche wurden umgesetzt"

  • Founder-Note. An alle User die im letzten Monat reagiert oder gepostet haben. Cron im mana-analytics, sendet via mana-mail/SMTP.

Aufwand: 4-6h (Templates + Cron + opt-out).


Phase 3.C — Identität & Sichtbarkeit (1 Tag)

3.C.1 — Opt-In Klarname-Toggle

auth.users neue Spalte:

ALTER TABLE auth.users
  ADD COLUMN community_show_real_name boolean NOT NULL DEFAULT false;

UI: Settings → Community → Switch "Klarnamen neben Pseudonym zeigen".

Server-Logik:

  • Im /api/v1/feedback/public (auth-required-Variante): wenn der ANGEFRAGTE post-Author community_show_real_name = true hat, extra Feld realName: 'Till' neben displayName. Sonst realName: undefined.
  • Im /api/v1/public/feedback/feed (anonymous-Variante): niemals Klarname ausliefern, egal was der Toggle sagt. Public-Mirror bleibt safe.

UI-Anzeige bei realName:

Wachsame Eule #4528 · Till

3.C.2 — Pixel-Art-Avatar aus display_hash

Pure-function Avatar-Generator (packages/feedback/src/avatar.ts):

export function generateAvatarSvg(displayHash: string): string {
  // Aus dem Hash 8-byte slices extrahieren:
  //  - eyeColor (3 bytes → HSL)
  //  - bodyColor (3 bytes → HSL)
  //  - pattern (1 byte → 8 sortes-of-feathers)
  //  - shape (1 byte → eule/otter/fuchs/...)
  
  return `<svg viewBox="0 0 32 32" xmlns="...">
    ${pixelGrid(shape, eyeColor, bodyColor, pattern)}
  </svg>`;
}

32×32 SVG, deterministisch, keine externes Dep, kein Storage. Wird in ItemCard.svelte neben dem Display-Name angezeigt.

Effekt: jedes Pseudonym hat ein Gesicht → wirklicher Charakter, nicht nur Text.

3.C.3 — Karma-System

Neue Spalte auf auth.users:

ALTER TABLE auth.users
  ADD COLUMN community_karma integer NOT NULL DEFAULT 0;

Karma += 1 für jede Reaction die jemand auf einem deiner Posts macht (post_owner_user_id-Lookup beim React).

Tier-Abbildung:

  • 0-9 Karma → Bronze Eule (default)
  • 10-49 → Silver Eule
  • 50-199 → Gold Eule
  • 200+ → Platinum Eule

UI: kleines Tier-Icon neben dem Display-Name (bronze 🦉₂, silver 🦉₃, …).

Realtime: bei jedem toggleReaction updaten wir Karma des Post-Owners atomar in derselben Transaction. Read-side: community_karma in Public-Feed-Output mit ausliefern.

3.C.4 — Eulen-Profil /community/eule/{display_hash}

Public-Page: zeigt alle Posts dieser Eule + Total-Karma + Tier-Badge + Avatar. Auch ohne Login zugänglich (SSR via Public-Endpoint).

GET /api/v1/public/feedback/eule/{display_hash} — neuer Endpoint.


Phase 3.D — Engagement-Mechaniken (1-2 Tage, später)

Diese Features bringen Fortgeschrittene-Engagement, kommen NACH 3.A-C live.

3.D.1 — "+12 wollen das auch"-Counter

Im FeedbackQuickModal beim Tippen: parallel ein semantischer Search gegen existierende Wishes. Wenn überschneidung > Schwelle:

Ähnliches existiert schon:
[Wachsame Eule] "Ich wünsche mir Y für meine Tagebücher"
🚀 12 weitere Eulen wollen das auch.

[Diesem Wisch beitreten] [Eigenen schreiben]

Multiplier-Effekt: 1-Klick-Mitvoten statt eigener Submit.

Server: POST /api/v1/feedback/match-existing mit feedbackText → LLM-Embedding gegen alle public top-level Items, gibt Top-3 zurück.

Items mit ≥5 neuen Reactions in 24h kriegen 🔥 Trending-Badge im Feed. Computed-Column oder Cron. UI: kleines flammendes Badge.

3.D.3 — Eulen-Compass

GET /api/v1/feedback/me/similar-eulen returns Top-5 Eulen, mit denen dein User die meisten Reactions teilt.

UI: kleiner Discovery-Block in /profile/my-wishes: "Du denkst ähnlich wie: Falke #2891, Otter #4823 …"

3.D.4 — Wochenquest

Cron schreibt jeden Montag eine Quest in _userQuests:

  • "5 Reactions abgeben → +10 Credits"
  • "Eigenen Wisch posten → +5 Credits"
  • "3 Replies posten → +5 Credits"

UI: Card in der Workbench (oder in /community/quest), zeigt Progress.


Phase 3.E — Smart-Triggers & Voice (Backlog)

3.E.1 — Frust-Detector

Module-Aktion 3× hintereinander aborted (cancel/back/error) → Pop-up:

Hängst du? Was nervt hier?
[Quick-Submit-Modal mit Module-Context vorausgefüllt]

Detection: action-event-counters in _uxTelemetry Dexie-Table.

3.E.2 — Post-Success-Rush

Mission completed, Goal erreicht, Drink-Streak gehalten →

🎉 Geschafft!
Was hat dir am meisten geholfen? (1 Klick → Lob-Submit)

3.E.3 — Voice-Submit

Mic-Icon im FeedbackQuickModal → @mana/local-stt (Whisper, WebGPU) transkribiert lokal. Senken die Hürde dramatisch (besonders mobil).

<MicrophoneButton onTranscript={(text) => bind value text} />

3-4h Code, weil local-stt schon da ist.

3.E.4 — Companion-Drafted-Wish

Companion bemerkt im Chat-Verlauf "X nervt" o.ä. → fragt einmal "Soll ich das fürs Team aufschreiben?" → drafted Wish, User reviewed.

Pattern: Companion liest periodisch deine eigenen Notes/Chats (lokal), sucht nach Frustsignal ('nervt'|'doof'|'kaputt'|...-pattern), bietet 1× per session den Submit-Helper an.


Phase 3.F — Legacy-Cleanup (2h, parallel zu 3.A)

Pre-launch ist die Chance, Halb-Migrationen sauber zu schließen.

3.F.1 — Drop vote/unvote/toggleVote/getPublicFeedback shims

In packages/feedback/src/createFeedbackService.ts:

// LEGACY (DROP):
async function vote(feedbackId): Promise<VoteResponse> { ... }
async function unvote(feedbackId): Promise<VoteResponse> { ... }
async function toggleVote(feedbackId): Promise<VoteResponse> { ... }
async function getPublicFeedback(query?): Promise<FeedbackListResponse> { ... }

Plus die VoteResponse-type in api.ts. Plus VoteButton.svelte (wir nutzen ReactionBar exklusiv). Plus userHasVoted-Feld auf Feedback.

3.F.2 — Drop voteCount-Spalte aus user_feedback

ALTER TABLE feedback.user_feedback DROP COLUMN vote_count;

Ist schon obsolet seit Phase 2 (Reactions ersetzen Votes vollständig), aber wir hatten's behalten "für Backward-Compat". Pre-launch braucht's das nicht.

3.F.3 — FeedbackPage.svelte refactor

Die Component aus dem Package ist Pre-Phase-2 (Tabs, Vote-Button, keine Reactions). Sie wird von apps/mana/apps/web/src/lib/modules/feedback/ListView.svelte benutzt — aber das ganze /feedback-Modul ist jetzt überflüssig (nur Bug-Reports, was Community macht besser).

Empfehlung: Modul /feedback ganz droppen. App-Registry-Eintrag löschen. Existierende Records bleiben in der DB, die werden ja über /community und /community/admin weiter angezeigt.

3.F.4 — Drop FeedbackVote-Type und feedback_votes Tabelle

feedback_votes ist schon weg (in 0002), aber der Type-Export ist nirgends entfernt. Drop alle Referenzen.


Reihenfolge

  1. Phase 3.A shipped 2026-04-27 — Credits-Loop
    • dbe24acfc + eecf64c1c (server + UI), e89958e9c (port-fix)
    • mana-credits transaction-type 'grant', /internal/credits/grant idempotent
    • mana-analytics +5/+500/+25 mit Founder-Whitelist + Rate-Limit
    • Reward-Chip in FeedbackQuickModal + Onboarding-Wish-Confirm
  2. Phase 3.F shipped 2026-04-27 — Legacy-Cleanup
    • eecf64c1c — vote/unvote/toggleVote shims raus, voteCount drop, /feedback Modul + Route gelöscht
  3. Phase 3.B shipped 2026-04-27 — Loop-Closure
    • 3a18a5e50 — feedback_notifications-Tabelle, Status-Notify + AdminResponse-Notify, Toast-Polling
    • /profile/my-wishes mit 3 Tabs (Eigene/Unterstützt/Inbox)
    • Migration 0004
  4. Phase 3.C shipped 2026-04-27 — Identität
    • ee5bb2871 + 1b30c3655 — Pixel-Identicon-Avatar (deterministic SVG), Klarname-Toggle mit Settings-Section, Karma-System mit Bronze/Silver/Gold/Platin-Tiers, /community/eule/[hash] Public-Profil mit SSR
    • Migration services/mana-auth/sql/008_community_identity.sql
    • Cross-schema-JOIN auth.users in mana-analytics
  5. Phase 3.D ⏸ offen — Engagement (Trending, Compass, Quests, Match-Existing)
  6. Phase 3.E ⏸ offen — Smart Triggers (Frust-Detect, Voice, Companion)

Datenmodell-Änderungen

mana-credits (mana_platform.credits)

-- Erweitert die transaction-type-Enum:
ALTER TYPE transaction_type ADD VALUE IF NOT EXISTS 'grant';

-- Neuer Index für Idempotency-Lookup auf metadata.referenceId:
CREATE INDEX IF NOT EXISTS transactions_reference_id_idx
  ON credits.transactions((metadata->>'referenceId'))
  WHERE metadata->>'referenceId' IS NOT NULL;

mana-analytics (mana_platform.feedback)

-- Mini-Tabelle für Rate-Limit-Counter:
CREATE TABLE IF NOT EXISTS feedback.feedback_grant_log (
  user_id text NOT NULL,
  granted_at timestamptz NOT NULL DEFAULT now(),
  reason text NOT NULL,
  PRIMARY KEY (user_id, granted_at)
);
CREATE INDEX feedback_grant_log_recent_idx
  ON feedback.feedback_grant_log(user_id, granted_at DESC);

-- Drop legacy:
ALTER TABLE feedback.user_feedback DROP COLUMN IF EXISTS vote_count;

auth (Phase 3.C)

ALTER TABLE auth.users
  ADD COLUMN IF NOT EXISTS community_show_real_name boolean NOT NULL DEFAULT false,
  ADD COLUMN IF NOT EXISTS community_karma integer NOT NULL DEFAULT 0;

Anti-Abuse-Patterns

Pattern Mechanismus Wo
Self-grant durch Founder FEEDBACK_FOUNDER_USER_IDS Whitelist mana-analytics service
Spam-Submit für Credits Rate-Limit 10/Tag, Mindest-20-Zeichen mana-analytics service
Empty/Junk-Submits LLM-Quality-Check (ist es ernst gemeint?) mana-analytics service, fail-soft
Reaktions-Farming Rate-Limit 30 reactions/Tag mana-analytics service
Multi-Account Sock-Puppets 1 user = 1 Eule (Pseudonym deterministisch); 1 reaction pro user pro emoji per item DB-Constraint feedback_reactions_unique
Doppel-Grants bei Re-Save referenceId als idempotency-key mana-credits transaction.metadata
Status-Hin-Und-Zurück → mehrfacher Ship-Bonus Trigger fires nur beim Frisch-Übergang, plus referenceId <id>_shipped mana-analytics adminUpdate

Risiken & Open Questions

  • Wie hoch ist 500 Credits "wirklich"? — Sync kostet 30/Monat → 500 = ~17 Monate Sync. Ist das OK? Falls zu großzügig: 200 Credits. Bauen wir mit 500, justieren später wenn nötig.
  • Founder-Whitelist auf prod: muss 1× per Hand gesetzt werden (FEEDBACK_FOUNDER_USER_IDS env), Memory-Pin nicht vergessen.
  • Spam-Detection durch LLM: optional fail-soft, aber wenn LLM down → einfach durchlassen (sonst legitimes Feedback blockiert).
  • Status-Notify-Spam: wenn Admin Status oft hin-und-her ändert → User wird mit Push-Notifications zugeschüttet. Mitigation: nur beim Frisch-Übergang notify, nicht bei jedem Status-Set. (Schon im Plan.)
  • Karma-Inflation: alte Posts kriegen über Zeit immer mehr Reactions, neue starten bei 0. Mitigation: Voting-Decay (Phase 3.D.X später).
  • "Founder bekommt selbst keine Credits": macht keine Founder-Wishes-Stories möglich ("Till hat sich das gewünscht"). Die Whitelist ist also strict. Alternative: Founder kriegen Credits aber als "FOUNDER"-Tier-Karma 0. Diskutierbar.

Test-Plan

Nach jedem Phase-Ship:

  1. Submit-Test: User postet einen Wisch ≥20 Zeichen → kriegt sofort +5 Credits sichtbar.
  2. Rate-Limit-Test: 11. Submit innerhalb 24h → kein Bonus, Submit gelingt aber.
  3. Founder-Test: Founder-User postet → kein Bonus.
  4. Ship-Test: Admin schiebt Wisch auf 'completed' → Original +500 Credits, Reagierer +25 Credits, Push-Notify ankommen.
  5. Ship-Idempotency: Admin schiebt 'completed' → 'in_progress' → 'completed' → keine Doppel-500-Credits.
  6. Klarname-Test: User aktiviert Toggle → in /community (auth) sichtbar; in mana.how/community (anonymous) nicht sichtbar.
  7. Karma-Test: User A postet, User B reagiert → A's community_karma += 1, A's Tier-Badge update.

Naming, Commit-Stil, etc.

  • Keine "v2"-Suffixe, kein "old/new"-Doppel-Code.
  • Migrationen werden 0003_<purpose>.sql, 0004_*.sql
  • Commit-Format wie bisher: feat(scope): kurze Beschreibung, body German+English mix erlaubt.
  • Plan-Doc-Updates inline, keine Sub-Docs (außer es wird sehr groß).