From dbe24acfc42d65cf6368032e1e2c792450a818d2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 14:13:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(feedback,credits):=20community-credit=20gr?= =?UTF-8?q?ants=20=E2=80=94=20+5=20submit=20/=20+500=20ship=20/=20+25=20re?= =?UTF-8?q?action-match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.A des feedback-rewards-and-identity-Plans. Direkter Reziprozitäts- Loop: User kriegt sofort etwas zurück fürs Mitwirken, Originalwunsch- Eulen werden beim Ship belohnt, Reagierer kriegen einen Anteil. mana-credits: - Neuer Endpoint POST /api/v1/internal/credits/grant + grantCredits() Service-Methode mit Idempotency via metadata.referenceId. - transaction_type-Enum erweitert um 'grant' (eigener Typ statt Mismatch mit 'refund'). - Migration 0001_grant_transaction_type.sql + partial-Index auf metadata->>'referenceId' für O(log n) Idempotency-Lookup. mana-analytics: - FeedbackService stempelt sofort +5 Credits beim createFeedback (top- level only, Replies bekommen nichts), wenn Mindest-20-Zeichen erfüllt und Rate-Limit (10/User/24h via feedback_grant_log) nicht überschritten. - adminUpdate triggert beim FRISCHEN Übergang nach 'completed': +500 Credits an Original-Wisher + +25 an alle, die mit 👍 oder 🚀 reagiert haben. Doppel-Pay strukturell unmöglich via referenceId (`_shipped`, `_reaction_`). - Founder-Whitelist via FEEDBACK_FOUNDER_USER_IDS env (verhindert Self-Reward). - Drop voteCount-Spalte (durch reactions/score seit 0002 ersetzt). - Migration 0003_grant_log_drop_vote_count.sql idempotent, lokal + prod eingespielt. Plan: docs/plans/feedback-rewards-and-identity.md (Phase 3.A-3.F). Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.macmini.yml | 5 + docs/plans/feedback-rewards-and-identity.md | 523 ++++++++++++++++++ .../0003_grant_log_drop_vote_count.sql | 26 + services/mana-analytics/src/config.ts | 14 + .../mana-analytics/src/db/schema/feedback.ts | 16 +- services/mana-analytics/src/index.ts | 9 +- .../mana-analytics/src/services/feedback.ts | 158 +++++- .../drizzle/0001_grant_transaction_type.sql | 23 + .../mana-credits/src/db/schema/credits.ts | 4 + services/mana-credits/src/lib/validation.ts | 8 + services/mana-credits/src/routes/internal.ts | 6 + services/mana-credits/src/services/credits.ts | 97 +++- 12 files changed, 882 insertions(+), 7 deletions(-) create mode 100644 docs/plans/feedback-rewards-and-identity.md create mode 100644 services/mana-analytics/drizzle/0003_grant_log_drop_vote_count.sql create mode 100644 services/mana-credits/drizzle/0001_grant_transaction_type.sql diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 112fb88fa..8664073e7 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -583,10 +583,15 @@ services: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform MANA_AUTH_URL: http://mana-auth:3001 MANA_LLM_URL: http://mana-llm:3025 + MANA_CREDITS_URL: http://mana-credits:3061 + MANA_SERVICE_KEY: ${MANA_SERVICE_KEY:?missing in .env.macmini} # Seeds the per-user display-hash for the public-community pseudonym # ("Wachsame Eule #4528"). Set in .env.macmini — rotation re-keys # only future pseudonyms, existing rows keep the old hash/name. FEEDBACK_PSEUDONYM_SECRET: ${FEEDBACK_PSEUDONYM_SECRET:?missing in .env.macmini} + # Comma-separated userIds that bypass community credit-grants + # (otherwise founders self-reward when posting/shipping). + FEEDBACK_FOUNDER_USER_IDS: ${FEEDBACK_FOUNDER_USER_IDS:-} CORS_ORIGINS: https://mana.how,https://community.mana.how ports: - "3064:3064" diff --git a/docs/plans/feedback-rewards-and-identity.md b/docs/plans/feedback-rewards-and-identity.md new file mode 100644 index 000000000..c50c3ac67 --- /dev/null +++ b/docs/plans/feedback-rewards-and-identity.md @@ -0,0 +1,523 @@ +--- +status: draft +owner: till +created: 2026-04-27 +parent: 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: +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: + +```ts +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 ``. + +**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): + +```ts +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: + +```sql +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`): + +```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 ` + ${pixelGrid(shape, eyeColor, bodyColor, pattern)} + `; +} +``` + +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`: + +```sql +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). + +```svelte + 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`: + +```ts +// LEGACY (DROP): +async function vote(feedbackId): Promise { ... } +async function unvote(feedbackId): Promise { ... } +async function toggleVote(feedbackId): Promise { ... } +async function getPublicFeedback(query?): Promise { ... } +``` + +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 + +```sql +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** *(jetzt)* — Credits-Loop + - mana-credits: `/internal/credits/grant` + `grantCredits()` + transaction-type 'grant' + - mana-analytics: `grantCreditsForSubmit` + `grantShipBonus` + Founder-Whitelist + Rate-Limit + - mana-web: UI-Toast "+5 Mana" im FeedbackQuickModal + Onboarding-Wish-Confirm + - 0003 SQL-Migration: `feedback_grant_log` mini-table + transaction.type-enum-Erweiterung +2. **Phase 3.F** *(direkt danach, 2h)* — Legacy-Cleanup + - vote/unvote/toggleVote/getPublicFeedback raus + - voteCount Spalte raus + - /feedback Modul + Route + ListView raus +3. **Phase 3.B** — Loop-Closure (Status-Notify + my-wishes + Digest) +4. **Phase 3.C** — Identität (Klarname + Pixel-Avatar + Karma + Eulen-Profil) +5. **Phase 3.D** — Engagement (Trending, Compass, Quests, Match-Existing) +6. **Phase 3.E** — Smart Triggers (Frust-Detect, Voice, Companion) + +--- + +## Datenmodell-Änderungen + +### mana-credits (`mana_platform.credits`) + +```sql +-- 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`) + +```sql +-- 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) + +```sql +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 `_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_.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ß). diff --git a/services/mana-analytics/drizzle/0003_grant_log_drop_vote_count.sql b/services/mana-analytics/drizzle/0003_grant_log_drop_vote_count.sql new file mode 100644 index 000000000..389a0dc51 --- /dev/null +++ b/services/mana-analytics/drizzle/0003_grant_log_drop_vote_count.sql @@ -0,0 +1,26 @@ +-- 0003_grant_log_drop_vote_count.sql +-- +-- Phase 3.A.2 von docs/plans/feedback-rewards-and-identity.md. +-- +-- 1. Mini-Tabelle feedback_grant_log als sliding-window Rate-Limit- +-- Counter für die Community-Credit-Grants (max 10/User/24h). +-- 2. Drop legacy vote_count column from user_feedback (Phase 3.F): +-- Reactions+score haben sie seit 0002 vollständig ersetzt. +-- +-- Apply manually before next push: +-- psql "$DATABASE_URL" -f services/mana-analytics/drizzle/0003_grant_log_drop_vote_count.sql + +BEGIN; + +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 +); + +CREATE INDEX IF NOT EXISTS feedback_grant_log_recent_idx + ON feedback.feedback_grant_log (user_id, granted_at); + +ALTER TABLE feedback.user_feedback DROP COLUMN IF EXISTS vote_count; + +COMMIT; diff --git a/services/mana-analytics/src/config.ts b/services/mana-analytics/src/config.ts index 85e5387bb..19bbdad5e 100644 --- a/services/mana-analytics/src/config.ts +++ b/services/mana-analytics/src/config.ts @@ -3,6 +3,7 @@ export interface Config { databaseUrl: string; manaAuthUrl: string; manaLlmUrl: string; + manaCreditsUrl: string; serviceKey: string; /** * Secret seeded into the per-user display-hash for the public-community @@ -10,6 +11,12 @@ export interface Config { * pseudonyms — existing rows keep the old hash/name. */ pseudonymSecret: string; + /** + * UserIds that bypass the +5 / +500 community credit grants — they would + * otherwise self-reward when posting/shipping their own wishes. Comma- + * separated env, empty in dev. + */ + founderUserIds: Set; cors: { origins: string[] }; } @@ -20,8 +27,15 @@ export function loadConfig(): Config { databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'), manaAuthUrl: env('MANA_AUTH_URL', 'http://localhost:3001'), manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'), + manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'), serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'), pseudonymSecret: env('FEEDBACK_PSEUDONYM_SECRET', 'dev-pseudonym-secret'), + founderUserIds: new Set( + env('FEEDBACK_FOUNDER_USER_IDS', '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + ), cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') }, }; } diff --git a/services/mana-analytics/src/db/schema/feedback.ts b/services/mana-analytics/src/db/schema/feedback.ts index 97a3cb48c..64694fca7 100644 --- a/services/mana-analytics/src/db/schema/feedback.ts +++ b/services/mana-analytics/src/db/schema/feedback.ts @@ -49,7 +49,6 @@ export const userFeedback = feedbackSchema.table( status: feedbackStatusEnum('status').default('submitted').notNull(), isPublic: boolean('is_public').default(true).notNull(), adminResponse: text('admin_response'), - voteCount: integer('vote_count').default(0).notNull(), // Public-community fields (Phase 2.1): // `display_hash` = SHA256(userId + serviceKey), never exposed. // `display_name` = deterministic Tier-pseudonym derived from hash. @@ -105,5 +104,20 @@ export const feedbackReactions = feedbackSchema.table( }) ); +// Append-only log of community-credit grants. Used as a sliding-window +// rate-limit counter ("max 10 grants per user per 24h") and as an audit +// trail. Cleanup of rows older than 7d is handled by a nightly cron. +export const feedbackGrantLog = feedbackSchema.table( + 'feedback_grant_log', + { + userId: text('user_id').notNull(), + grantedAt: timestamp('granted_at', { withTimezone: true }).defaultNow().notNull(), + reason: text('reason').notNull(), + }, + (table) => ({ + recentIdx: index('feedback_grant_log_recent_idx').on(table.userId, table.grantedAt), + }) +); + export type Feedback = typeof userFeedback.$inferSelect; export type FeedbackReaction = typeof feedbackReactions.$inferSelect; diff --git a/services/mana-analytics/src/index.ts b/services/mana-analytics/src/index.ts index acc180ab1..9a7220e31 100644 --- a/services/mana-analytics/src/index.ts +++ b/services/mana-analytics/src/index.ts @@ -20,7 +20,14 @@ import { createPublicFeedbackRoutes } from './routes/public'; const config = loadConfig(); const db = getDb(config.databaseUrl); -const feedbackService = new FeedbackService(db, config.manaLlmUrl, config.pseudonymSecret); +const feedbackService = new FeedbackService( + db, + config.manaLlmUrl, + config.pseudonymSecret, + config.manaCreditsUrl, + config.serviceKey, + config.founderUserIds +); const app = new Hono(); diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts index 0d15a70cb..7f3e801a0 100644 --- a/services/mana-analytics/src/services/feedback.ts +++ b/services/mana-analytics/src/services/feedback.ts @@ -12,12 +12,31 @@ * column. */ -import { eq, and, desc, sql, isNull } from 'drizzle-orm'; -import { userFeedback, feedbackReactions } from '../db/schema/feedback'; +import { eq, and, desc, sql, isNull, gte, inArray } from 'drizzle-orm'; +import { userFeedback, feedbackReactions, feedbackGrantLog } from '../db/schema/feedback'; import type { Database } from '../db/connection'; import { NotFoundError, BadRequestError } from '../lib/errors'; import { createDisplayHash, generateDisplayName } from '../lib/pseudonym'; +/** + * Reward amounts (community-credit grants). Lives next to the policy + * so it's obvious in code review. Tweak here, no DB migration needed. + */ +const REWARD = { + submit: 5, + shipped: 500, + reactionMatch: 25, +} as const; + +/** Min chars before a submit qualifies for the +5 bonus (anti-junk). */ +const MIN_SUBMIT_CHARS_FOR_BONUS = 20; + +/** Max grants per user per 24h (sliding window via feedback_grant_log). */ +const MAX_GRANTS_PER_24H = 10; + +/** Reactioner-bonus is only paid for these "I want this"-emojis, not 🤔. */ +const SHIP_BONUS_REACTION_EMOJIS = ['👍', '🚀'] as const; + /** * Allowed reaction emojis with sort-score weights. * Add a new emoji here to make it submittable. @@ -54,7 +73,13 @@ export class FeedbackService { private db: Database, private llmUrl: string, /** Secret used to derive non-reversible per-user display hashes. */ - private pseudonymSecret: string + private pseudonymSecret: string, + /** mana-credits internal API base, used for community grants. */ + private creditsUrl: string, + /** Service-key for X-Service-Key header on internal calls. */ + private serviceKey: string, + /** UserIds that should not receive +5 / +500 community grants. */ + private founderUserIds: Set ) {} // ── Submission ──────────────────────────────────────────────────── @@ -104,9 +129,70 @@ export class FeedbackService { }) .returning(); + // Fire-and-forget reward grant. Failure must not block the + // submission — credits service is non-critical for the user + // flow. Replies (parentId set) skip the bonus to avoid + // rewarding chatter; only top-level wishes count. + if (!data.parentId) { + void this.tryGrantSubmitBonus(feedback); + } + return feedback; } + private async tryGrantSubmitBonus(feedback: typeof userFeedback.$inferSelect): Promise { + try { + if (this.founderUserIds.has(feedback.userId)) return; + if (feedback.feedbackText.trim().length < MIN_SUBMIT_CHARS_FOR_BONUS) return; + if (await this.exceedsGrantRateLimit(feedback.userId)) return; + + await this.grantCredits({ + userId: feedback.userId, + amount: REWARD.submit, + reason: 'feedback_submit', + referenceId: feedback.id, + description: `Danke für dein Feedback (${feedback.category})`, + }); + } catch (err) { + console.warn('[feedback] submit bonus failed (non-blocking):', err); + } + } + + private async exceedsGrantRateLimit(userId: string): Promise { + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + const rows = await this.db + .select({ ct: sql`count(*)::int` }) + .from(feedbackGrantLog) + .where(and(eq(feedbackGrantLog.userId, userId), gte(feedbackGrantLog.grantedAt, since))); + return (rows[0]?.ct ?? 0) >= MAX_GRANTS_PER_24H; + } + + private async grantCredits(args: { + userId: string; + amount: number; + reason: string; + referenceId: string; + description?: string; + }): Promise { + const res = await fetch(`${this.creditsUrl}/api/v1/internal/credits/grant`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Service-Key': this.serviceKey, + }, + body: JSON.stringify(args), + }); + if (!res.ok) { + throw new Error(`grant failed (${res.status}): ${await res.text().catch(() => '')}`); + } + const body = (await res.json()) as { alreadyGranted?: boolean }; + // Only count fresh grants against the rate limit. Idempotent + // re-tries (alreadyGranted=true) shouldn't burn the budget. + if (!body.alreadyGranted) { + await this.db.insert(feedbackGrantLog).values({ userId: args.userId, reason: args.reason }); + } + } + // ── Public reads (no auth) ──────────────────────────────────────── /** @@ -305,6 +391,13 @@ export class FeedbackService { feedbackId: string, patch: { status?: string; adminResponse?: string; isPublic?: boolean } ) { + const [before] = await this.db + .select() + .from(userFeedback) + .where(eq(userFeedback.id, feedbackId)) + .limit(1); + if (!before) throw new NotFoundError('Feedback not found'); + const update: Record = { updatedAt: new Date() }; if (patch.status !== undefined) update.status = patch.status; if (patch.adminResponse !== undefined) update.adminResponse = patch.adminResponse; @@ -315,10 +408,67 @@ export class FeedbackService { .set(update) .where(eq(userFeedback.id, feedbackId)) .returning(); - if (!row) throw new NotFoundError('Feedback not found'); + + // Ship-Bonus: only on the FRESH 'completed' transition. Status- + // flapping ('completed' → 'in_progress' → 'completed') won't + // double-pay because the credit grant is keyed off + // `${id}_shipped`, but skipping the trigger entirely keeps the + // reactioner-bonus loop from spamming as well. + if (before.status !== 'completed' && row.status === 'completed') { + void this.tryGrantShipBonus(row); + } + return row; } + private async tryGrantShipBonus(feedback: typeof userFeedback.$inferSelect): Promise { + try { + // Original wisher gets the +500. + if (!this.founderUserIds.has(feedback.userId)) { + await this.grantCredits({ + userId: feedback.userId, + amount: REWARD.shipped, + reason: 'feedback_shipped', + referenceId: `${feedback.id}_shipped`, + description: `Dein Wunsch ›${feedback.title ?? feedback.feedbackText.slice(0, 40)}‹ ist live`, + }); + } + + // Reactioners who pushed for this with 👍 or 🚀 each get +25. + const reactionRows = await this.db + .select({ userId: feedbackReactions.userId, emoji: feedbackReactions.emoji }) + .from(feedbackReactions) + .where( + and( + eq(feedbackReactions.feedbackId, feedback.id), + inArray(feedbackReactions.emoji, [...SHIP_BONUS_REACTION_EMOJIS]) + ) + ); + + // One reward per user even if they reacted with multiple emojis. + const supporters = new Set(); + for (const r of reactionRows) supporters.add(r.userId); + supporters.delete(feedback.userId); // author already got the big bonus + for (const fid of this.founderUserIds) supporters.delete(fid); + + for (const supporter of supporters) { + try { + await this.grantCredits({ + userId: supporter, + amount: REWARD.reactionMatch, + reason: 'feedback_reaction_match', + referenceId: `${feedback.id}_reaction_${supporter}`, + description: `Du hast ›${feedback.title ?? '(Wunsch)'}‹ unterstützt — danke!`, + }); + } catch (err) { + console.warn('[feedback] reactioner-bonus failed for', supporter, err); + } + } + } catch (err) { + console.warn('[feedback] ship bonus failed (non-blocking):', err); + } + } + // ── LLM helpers ─────────────────────────────────────────────────── private async generateTitle(text: string): Promise { diff --git a/services/mana-credits/drizzle/0001_grant_transaction_type.sql b/services/mana-credits/drizzle/0001_grant_transaction_type.sql new file mode 100644 index 000000000..6889469be --- /dev/null +++ b/services/mana-credits/drizzle/0001_grant_transaction_type.sql @@ -0,0 +1,23 @@ +-- 0001_grant_transaction_type.sql +-- +-- Phase 3.A.1 von docs/plans/feedback-rewards-and-identity.md. +-- +-- Erweitert credits.transaction_type um 'grant' für Reward-Auszahlungen +-- (mana-analytics ruft /internal/credits/grant für +5 / +500 / +25 +-- Belohnungen auf). Plus ein partial-Index auf metadata.referenceId, +-- damit Idempotency-Lookup beim Re-Grant in O(log n) läuft. +-- +-- Apply manually before next `pnpm db:push`: +-- psql "$DATABASE_URL" -f services/mana-credits/drizzle/0001_grant_transaction_type.sql +-- +-- Idempotent via IF NOT EXISTS / ADD VALUE IF NOT EXISTS. + +BEGIN; + +ALTER TYPE public.transaction_type ADD VALUE IF NOT EXISTS 'grant'; + +CREATE INDEX IF NOT EXISTS transactions_reference_id_idx + ON credits.transactions ((metadata ->> 'referenceId')) + WHERE metadata ->> 'referenceId' IS NOT NULL; + +COMMIT; diff --git a/services/mana-credits/src/db/schema/credits.ts b/services/mana-credits/src/db/schema/credits.ts index a95138d51..4c8d10988 100644 --- a/services/mana-credits/src/db/schema/credits.ts +++ b/services/mana-credits/src/db/schema/credits.ts @@ -21,11 +21,15 @@ export const creditsSchema = pgSchema('credits'); // ─── Enums ────────────────────────────────────────────────── +// Enum values must mirror cross-service consumers. New values need a +// hand-authored SQL migration (drizzle-kit push won't add enum members +// reliably across mana-credits' own pgEnum installs). export const transactionTypeEnum = pgEnum('transaction_type', [ 'purchase', 'usage', 'refund', 'gift', + 'grant', ]); export const transactionStatusEnum = pgEnum('transaction_status', [ diff --git a/services/mana-credits/src/lib/validation.ts b/services/mana-credits/src/lib/validation.ts index 875dd17c5..6b7c845c3 100644 --- a/services/mana-credits/src/lib/validation.ts +++ b/services/mana-credits/src/lib/validation.ts @@ -75,6 +75,14 @@ export const internalRedeemPendingSchema = z.object({ email: z.string().email(), }); +export const internalGrantSchema = z.object({ + userId: z.string().min(1), + amount: z.number().int().positive(), + reason: z.string().min(1).max(64), + referenceId: z.string().min(1).max(128), + description: z.string().max(256).optional(), +}); + // ─── Reservations (2-phase debit) ────────────────────────── export const internalReserveSchema = z.object({ diff --git a/services/mana-credits/src/routes/internal.ts b/services/mana-credits/src/routes/internal.ts index 01479c1c2..0d6b09819 100644 --- a/services/mana-credits/src/routes/internal.ts +++ b/services/mana-credits/src/routes/internal.ts @@ -14,6 +14,7 @@ import { internalReserveSchema, internalCommitSchema, internalRefundReservationSchema, + internalGrantSchema, } from '../lib/validation'; export function createInternalRoutes( @@ -43,6 +44,11 @@ export function createInternalRoutes( ); return c.json(result); }) + .post('/credits/grant', async (c) => { + const body = internalGrantSchema.parse(await c.req.json()); + const result = await creditsService.grantCredits(body); + return c.json(result); + }) .post('/credits/init', async (c) => { const body = internalInitSchema.parse(await c.req.json()); const balance = await creditsService.initializeBalance(body.userId); diff --git a/services/mana-credits/src/services/credits.ts b/services/mana-credits/src/services/credits.ts index 2449f1d85..df39ea4e7 100644 --- a/services/mana-credits/src/services/credits.ts +++ b/services/mana-credits/src/services/credits.ts @@ -5,7 +5,7 @@ * Handles balance CRUD, credit usage, purchases, and transaction ledger. */ -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, sql } from 'drizzle-orm'; import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits'; import { creditReservations } from '../db/schema/reservations'; import type { Database } from '../db/connection'; @@ -198,6 +198,101 @@ export class CreditsService { }); } + /** + * Grant credits to a user as a reward (no money changed hands). + * Idempotent on `referenceId`: if a previous grant with the same + * referenceId already landed, returns `alreadyGranted: true` without + * mutating balance. + * + * Used by mana-analytics to drop +5 Credits for every quality + * feedback submission and +500 Credits when a wish ships, plus +25 + * to each reactioner whose vote nudged the wish toward 'completed'. + */ + async grantCredits(params: { + userId: string; + amount: number; + reason: string; + referenceId: string; + description?: string; + }) { + if (params.amount <= 0) throw new BadRequestError('amount must be > 0'); + if (!params.referenceId) throw new BadRequestError('referenceId is required for idempotency'); + + // Idempotency: short-circuit if this referenceId already produced a grant. + const existing = await this.db + .select({ id: transactions.id, balanceAfter: transactions.balanceAfter }) + .from(transactions) + .where( + and( + eq(transactions.userId, params.userId), + eq(transactions.type, 'grant'), + sql`${transactions.metadata}->>'referenceId' = ${params.referenceId}` + ) + ) + .limit(1); + + if (existing.length > 0) { + return { ok: true, alreadyGranted: true, newBalance: existing[0].balanceAfter }; + } + + return await this.db.transaction(async (tx) => { + // Ensure balance row exists. + const [current] = await tx + .select() + .from(balances) + .where(eq(balances.userId, params.userId)) + .for('update') + .limit(1); + + let balanceBefore: number; + let totalEarnedBefore: number; + let version: number; + if (!current) { + const [created] = await tx + .insert(balances) + .values({ userId: params.userId, balance: 0, totalEarned: 0, totalSpent: 0 }) + .returning(); + balanceBefore = created.balance; + totalEarnedBefore = created.totalEarned; + version = created.version; + } else { + balanceBefore = current.balance; + totalEarnedBefore = current.totalEarned; + version = current.version; + } + + const newBalance = balanceBefore + params.amount; + + await tx + .update(balances) + .set({ + balance: newBalance, + totalEarned: totalEarnedBefore + params.amount, // grants count as earned + version: version + 1, + updatedAt: new Date(), + }) + .where(and(eq(balances.userId, params.userId), eq(balances.version, version))); + + const [transaction] = await tx + .insert(transactions) + .values({ + userId: params.userId, + type: 'grant', + status: 'completed', + amount: params.amount, + balanceBefore, + balanceAfter: newBalance, + appId: 'community', + description: params.description ?? `Reward: ${params.reason}`, + metadata: { reason: params.reason, referenceId: params.referenceId }, + completedAt: new Date(), + }) + .returning(); + + return { ok: true, alreadyGranted: false, newBalance, transactionId: transaction.id }; + }); + } + async getTransactions(userId: string, limit = 50, offset = 0) { return this.db .select()