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:
Till JS 2026-04-27 14:13:46 +02:00
parent e712faf7b7
commit dbe24acfc4
12 changed files with 882 additions and 7 deletions

View file

@ -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;

View file

@ -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<string>;
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(',') },
};
}

View file

@ -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;

View file

@ -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();

View file

@ -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<string>
) {}
// ── 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<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) ────────────────────────────────────────
/**
@ -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<string, unknown> = { 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<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 ───────────────────────────────────────────────────
private async generateTitle(text: string): Promise<string> {