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
|
|
@ -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;
|
||||
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(',') },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue