mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(feedback,credits): community-credit grants — +5 submit / +500 ship / +25 reaction-match
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 (`<id>_shipped`, `<id>_reaction_<userId>`). - 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) <noreply@anthropic.com>
This commit is contained in:
parent
e712faf7b7
commit
dbe24acfc4
12 changed files with 882 additions and 7 deletions
|
|
@ -583,10 +583,15 @@ services:
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform
|
||||||
MANA_AUTH_URL: http://mana-auth:3001
|
MANA_AUTH_URL: http://mana-auth:3001
|
||||||
MANA_LLM_URL: http://mana-llm:3025
|
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
|
# Seeds the per-user display-hash for the public-community pseudonym
|
||||||
# ("Wachsame Eule #4528"). Set in .env.macmini — rotation re-keys
|
# ("Wachsame Eule #4528"). Set in .env.macmini — rotation re-keys
|
||||||
# only future pseudonyms, existing rows keep the old hash/name.
|
# only future pseudonyms, existing rows keep the old hash/name.
|
||||||
FEEDBACK_PSEUDONYM_SECRET: ${FEEDBACK_PSEUDONYM_SECRET:?missing in .env.macmini}
|
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
|
CORS_ORIGINS: https://mana.how,https://community.mana.how
|
||||||
ports:
|
ports:
|
||||||
- "3064:3064"
|
- "3064:3064"
|
||||||
|
|
|
||||||
523
docs/plans/feedback-rewards-and-identity.md
Normal file
523
docs/plans/feedback-rewards-and-identity.md
Normal file
|
|
@ -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: <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 `<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):
|
||||||
|
|
||||||
|
```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 `<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`:
|
||||||
|
|
||||||
|
```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
|
||||||
|
<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`:
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
```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 `<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ß).
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -3,6 +3,7 @@ export interface Config {
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
manaAuthUrl: string;
|
manaAuthUrl: string;
|
||||||
manaLlmUrl: string;
|
manaLlmUrl: string;
|
||||||
|
manaCreditsUrl: string;
|
||||||
serviceKey: string;
|
serviceKey: string;
|
||||||
/**
|
/**
|
||||||
* Secret seeded into the per-user display-hash for the public-community
|
* 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.
|
* pseudonyms — existing rows keep the old hash/name.
|
||||||
*/
|
*/
|
||||||
pseudonymSecret: string;
|
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<string>;
|
||||||
cors: { origins: string[] };
|
cors: { origins: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,8 +27,15 @@ export function loadConfig(): Config {
|
||||||
databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'),
|
databaseUrl: env('DATABASE_URL', 'postgresql://mana:devpassword@localhost:5432/mana_platform'),
|
||||||
manaAuthUrl: env('MANA_AUTH_URL', 'http://localhost:3001'),
|
manaAuthUrl: env('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||||
manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'),
|
manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'),
|
||||||
|
manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'),
|
||||||
serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'),
|
serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'),
|
||||||
pseudonymSecret: env('FEEDBACK_PSEUDONYM_SECRET', 'dev-pseudonym-secret'),
|
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(',') },
|
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ export const userFeedback = feedbackSchema.table(
|
||||||
status: feedbackStatusEnum('status').default('submitted').notNull(),
|
status: feedbackStatusEnum('status').default('submitted').notNull(),
|
||||||
isPublic: boolean('is_public').default(true).notNull(),
|
isPublic: boolean('is_public').default(true).notNull(),
|
||||||
adminResponse: text('admin_response'),
|
adminResponse: text('admin_response'),
|
||||||
voteCount: integer('vote_count').default(0).notNull(),
|
|
||||||
// Public-community fields (Phase 2.1):
|
// Public-community fields (Phase 2.1):
|
||||||
// `display_hash` = SHA256(userId + serviceKey), never exposed.
|
// `display_hash` = SHA256(userId + serviceKey), never exposed.
|
||||||
// `display_name` = deterministic Tier-pseudonym derived from hash.
|
// `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 Feedback = typeof userFeedback.$inferSelect;
|
||||||
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
|
export type FeedbackReaction = typeof feedbackReactions.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,14 @@ import { createPublicFeedbackRoutes } from './routes/public';
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const db = getDb(config.databaseUrl);
|
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();
|
const app = new Hono();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,31 @@
|
||||||
* column.
|
* column.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, and, desc, sql, isNull } from 'drizzle-orm';
|
import { eq, and, desc, sql, isNull, gte, inArray } from 'drizzle-orm';
|
||||||
import { userFeedback, feedbackReactions } from '../db/schema/feedback';
|
import { userFeedback, feedbackReactions, feedbackGrantLog } from '../db/schema/feedback';
|
||||||
import type { Database } from '../db/connection';
|
import type { Database } from '../db/connection';
|
||||||
import { NotFoundError, BadRequestError } from '../lib/errors';
|
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||||
import { createDisplayHash, generateDisplayName } from '../lib/pseudonym';
|
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.
|
* Allowed reaction emojis with sort-score weights.
|
||||||
* Add a new emoji here to make it submittable.
|
* Add a new emoji here to make it submittable.
|
||||||
|
|
@ -54,7 +73,13 @@ export class FeedbackService {
|
||||||
private db: Database,
|
private db: Database,
|
||||||
private llmUrl: string,
|
private llmUrl: string,
|
||||||
/** Secret used to derive non-reversible per-user display hashes. */
|
/** 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<string>
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Submission ────────────────────────────────────────────────────
|
// ── Submission ────────────────────────────────────────────────────
|
||||||
|
|
@ -104,9 +129,70 @@ export class FeedbackService {
|
||||||
})
|
})
|
||||||
.returning();
|
.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;
|
return feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryGrantSubmitBonus(feedback: typeof userFeedback.$inferSelect): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ ct: sql<number>`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<void> {
|
||||||
|
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) ────────────────────────────────────────
|
// ── Public reads (no auth) ────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -305,6 +391,13 @@ export class FeedbackService {
|
||||||
feedbackId: string,
|
feedbackId: string,
|
||||||
patch: { status?: string; adminResponse?: string; isPublic?: boolean }
|
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<string, unknown> = { updatedAt: new Date() };
|
const update: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (patch.status !== undefined) update.status = patch.status;
|
if (patch.status !== undefined) update.status = patch.status;
|
||||||
if (patch.adminResponse !== undefined) update.adminResponse = patch.adminResponse;
|
if (patch.adminResponse !== undefined) update.adminResponse = patch.adminResponse;
|
||||||
|
|
@ -315,10 +408,67 @@ export class FeedbackService {
|
||||||
.set(update)
|
.set(update)
|
||||||
.where(eq(userFeedback.id, feedbackId))
|
.where(eq(userFeedback.id, feedbackId))
|
||||||
.returning();
|
.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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryGrantShipBonus(feedback: typeof userFeedback.$inferSelect): Promise<void> {
|
||||||
|
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<string>();
|
||||||
|
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 ───────────────────────────────────────────────────
|
// ── LLM helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private async generateTitle(text: string): Promise<string> {
|
private async generateTitle(text: string): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -21,11 +21,15 @@ export const creditsSchema = pgSchema('credits');
|
||||||
|
|
||||||
// ─── Enums ──────────────────────────────────────────────────
|
// ─── 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', [
|
export const transactionTypeEnum = pgEnum('transaction_type', [
|
||||||
'purchase',
|
'purchase',
|
||||||
'usage',
|
'usage',
|
||||||
'refund',
|
'refund',
|
||||||
'gift',
|
'gift',
|
||||||
|
'grant',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const transactionStatusEnum = pgEnum('transaction_status', [
|
export const transactionStatusEnum = pgEnum('transaction_status', [
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ export const internalRedeemPendingSchema = z.object({
|
||||||
email: z.string().email(),
|
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) ──────────────────────────
|
// ─── Reservations (2-phase debit) ──────────────────────────
|
||||||
|
|
||||||
export const internalReserveSchema = z.object({
|
export const internalReserveSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
internalReserveSchema,
|
internalReserveSchema,
|
||||||
internalCommitSchema,
|
internalCommitSchema,
|
||||||
internalRefundReservationSchema,
|
internalRefundReservationSchema,
|
||||||
|
internalGrantSchema,
|
||||||
} from '../lib/validation';
|
} from '../lib/validation';
|
||||||
|
|
||||||
export function createInternalRoutes(
|
export function createInternalRoutes(
|
||||||
|
|
@ -43,6 +44,11 @@ export function createInternalRoutes(
|
||||||
);
|
);
|
||||||
return c.json(result);
|
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) => {
|
.post('/credits/init', async (c) => {
|
||||||
const body = internalInitSchema.parse(await c.req.json());
|
const body = internalInitSchema.parse(await c.req.json());
|
||||||
const balance = await creditsService.initializeBalance(body.userId);
|
const balance = await creditsService.initializeBalance(body.userId);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Handles balance CRUD, credit usage, purchases, and transaction ledger.
|
* 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 { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits';
|
||||||
import { creditReservations } from '../db/schema/reservations';
|
import { creditReservations } from '../db/schema/reservations';
|
||||||
import type { Database } from '../db/connection';
|
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) {
|
async getTransactions(userId: string, limit = 50, offset = 0) {
|
||||||
return this.db
|
return this.db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue