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>
18 KiB
| 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
- 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.
- Loop-Closure ist Pflicht. Wer reagiert hat, muss erfahren wenn sein Item shipped. Stiller Erfolg ist kein Erfolg.
- 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.
- Karma ist visible. Eine Eule mit 200 Karma wirkt anders als eine #1. Reputation entsteht ohne Identität.
- Anti-Abuse passiv. Rate-Limits + Founder-Whitelist + LLM-Spam- Filter laufen serverseitig, nie als UI-Friktion.
- 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 submitted → planned → in_progress →
completed 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:
submitted→planned: "Geplant: dein Wunsch ›{title}‹ ist auf der Roadmap"planned→in_progress: "Wir bauen ›{title}‹ gerade"in_progress→completed: "🎉 ›{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-Authorcommunity_show_real_name = truehat, extra FeldrealName: 'Till'nebendisplayName. SonstrealName: 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.
3.D.2 — 🔥 Trending-Badge
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
- Phase 3.A ✅ shipped 2026-04-27 — Credits-Loop
- Phase 3.F ✅ shipped 2026-04-27 — Legacy-Cleanup
eecf64c1c— vote/unvote/toggleVote shims raus, voteCount drop, /feedback Modul + Route gelöscht
- 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
- 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
- Phase 3.D ⏸ offen — Engagement (Trending, Compass, Quests, Match-Existing)
- 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:
- Submit-Test: User postet einen Wisch ≥20 Zeichen → kriegt sofort +5 Credits sichtbar.
- Rate-Limit-Test: 11. Submit innerhalb 24h → kein Bonus, Submit gelingt aber.
- Founder-Test: Founder-User postet → kein Bonus.
- Ship-Test: Admin schiebt Wisch auf 'completed' → Original +500 Credits, Reagierer +25 Credits, Push-Notify ankommen.
- Ship-Idempotency: Admin schiebt 'completed' → 'in_progress' → 'completed' → keine Doppel-500-Credits.
- Klarname-Test: User aktiviert Toggle → in
/community(auth) sichtbar; inmana.how/community(anonymous) nicht sichtbar. - 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ß).