managarten/services/mana-analytics/src/db/schema/feedback.ts
Till JS dbe24acfc4 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>
2026-04-27 14:13:46 +02:00

123 lines
4.5 KiB
TypeScript

import {
pgSchema,
uuid,
text,
timestamp,
integer,
boolean,
jsonb,
index,
unique,
pgEnum,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
export const feedbackSchema = pgSchema('feedback');
// Enum values must mirror @mana/feedback's FeedbackCategory / FeedbackStatus
// unions exactly. Renames or additions need a hand-authored SQL migration
// under services/mana-analytics/drizzle/ (drizzle-kit push can't safely
// rename enum values).
export const feedbackCategoryEnum = pgEnum('feedback_category', [
'bug',
'feature',
'improvement',
'question',
'praise',
'onboarding-wish',
'other',
]);
export const feedbackStatusEnum = pgEnum('feedback_status', [
'submitted',
'under_review',
'planned',
'in_progress',
'completed',
'declined',
]);
export const userFeedback = feedbackSchema.table(
'user_feedback',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
appId: text('app_id').notNull(),
title: text('title'),
feedbackText: text('feedback_text').notNull(),
category: feedbackCategoryEnum('category').default('other').notNull(),
status: feedbackStatusEnum('status').default('submitted').notNull(),
isPublic: boolean('is_public').default(true).notNull(),
adminResponse: text('admin_response'),
// Public-community fields (Phase 2.1):
// `display_hash` = SHA256(userId + serviceKey), never exposed.
// `display_name` = deterministic Tier-pseudonym derived from hash.
// Server stamps both on insert; clients receive only display_name.
displayHash: text('display_hash'),
displayName: text('display_name'),
// `module_context` is set by inline FeedbackHook submissions so the
// public feed can filter / badge by module ('todo', 'notes', …).
moduleContext: text('module_context'),
// `parent_id` enables 1-level reply threading on feedback items.
parentId: uuid('parent_id').references((): AnyPgColumn => userFeedback.id, {
onDelete: 'set null',
}),
// Cached per-emoji counter map, e.g. {"👍": 12, "❤️": 4, "🚀": 2}.
// Source of truth lives in `feedback_reactions`; this column is
// recomputed on every react/unreact for cheap reads.
reactions: jsonb('reactions').default({}).notNull(),
// Cached sort score (weighted reactions sum). Sort the public feed
// on this column instead of recomputing per-row from `reactions`.
score: integer('score').default(0).notNull(),
deviceInfo: jsonb('device_info'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdIdx: index('feedback_user_id_idx').on(table.userId),
appIdIdx: index('feedback_app_id_idx').on(table.appId),
statusIdx: index('feedback_status_idx').on(table.status),
displayHashIdx: index('feedback_display_hash_idx').on(table.displayHash),
moduleContextIdx: index('feedback_module_context_idx').on(table.moduleContext),
parentIdIdx: index('feedback_parent_id_idx').on(table.parentId),
scoreIdx: index('feedback_score_idx').on(table.score),
})
);
// Reactions table: one row per (item, user, emoji). Slack-pattern:
// a user can stack multiple emojis on the same item. Aggregated counts
// are mirrored into `user_feedback.reactions` for cheap reads.
export const feedbackReactions = feedbackSchema.table(
'feedback_reactions',
{
id: uuid('id').primaryKey().defaultRandom(),
feedbackId: uuid('feedback_id')
.notNull()
.references(() => userFeedback.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull(),
emoji: text('emoji').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
uniq: unique('feedback_reactions_unique').on(table.feedbackId, table.userId, table.emoji),
feedbackIdx: index('feedback_reactions_feedback_idx').on(table.feedbackId),
})
);
// 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;