From 8b0a943e7198775ed1c466bd364b0465b6147559 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 00:00:35 +0200 Subject: [PATCH] feat(mana-analytics): pseudonym + reactions + public feed + admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macht mana-analytics zur Backend-Heimat des Public-Community-Hubs. - Pseudonym: createDisplayHash(userId, secret) + generateDisplayName() produzieren deterministisch "Wachsame Eule #4528" pro User. 100 Adjektive × 80 Tieren × 10000 Suffixe → ~80M Kombinationen. 7 unit tests, 0 PII im Output. - Schema-Erweiterung user_feedback: display_hash, display_name, module_context, parent_id (1-level Threading), reactions jsonb (cached emoji→count), score (cached weighted sort). - feedback_votes ersetzt durch feedback_reactions (Slack-Pattern: unique(feedback_id, user_id, emoji), pro User mehrere Emojis möglich). - Service: createFeedback stempelt display_hash + display_name. Neue Methoden getPublicFeed (redacted), getReplies, toggleReaction (rebuilt reactions+score). Admin-Methoden adminListAll/adminUpdate founder-tier-gated im Route-Layer. - Routes: /api/v1/public/feedback/* — anonymous reads (kein Auth, kein userId/displayHash/deviceInfo im Output) /api/v1/feedback/* — auth-required Submit/React/Manage, plus :id/replies, :id/react, /admin - Config: neuer FEEDBACK_PSEUDONYM_SECRET-Env-Var seedet die Hashes; Rotation re-keyt nur künftige Pseudonyme, alte Records bleiben stabil. Migration 0002_public-community-foundation.sql idempotent, lokal + prod (mana-server) eingespielt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0002_public-community-foundation.sql | 51 +++ services/mana-analytics/src/config.ts | 12 +- .../mana-analytics/src/db/schema/feedback.ts | 38 ++- services/mana-analytics/src/index.ts | 14 +- .../mana-analytics/src/lib/pseudonym.test.ts | 49 +++ services/mana-analytics/src/lib/pseudonym.ts | 241 ++++++++++++++ .../mana-analytics/src/routes/feedback.ts | 143 +++++++-- services/mana-analytics/src/routes/public.ts | 44 +++ .../mana-analytics/src/services/feedback.ts | 298 ++++++++++++++++-- 9 files changed, 820 insertions(+), 70 deletions(-) create mode 100644 services/mana-analytics/drizzle/0002_public-community-foundation.sql create mode 100644 services/mana-analytics/src/lib/pseudonym.test.ts create mode 100644 services/mana-analytics/src/lib/pseudonym.ts create mode 100644 services/mana-analytics/src/routes/public.ts diff --git a/services/mana-analytics/drizzle/0002_public-community-foundation.sql b/services/mana-analytics/drizzle/0002_public-community-foundation.sql new file mode 100644 index 000000000..5e49a457c --- /dev/null +++ b/services/mana-analytics/drizzle/0002_public-community-foundation.sql @@ -0,0 +1,51 @@ +-- 0002_public-community-foundation.sql +-- +-- Phase 2.1 von docs/plans/feedback-hub-public.md. +-- +-- Macht user_feedback bereit für die Public-Community-Surface: +-- Pseudonym (display_hash + display_name), Modul-Kontext, 1-Level-Reply- +-- Threading, Slack-Style-Reactions (statt simpler Vote-Counter), Cached- +-- Score für Sort. +-- +-- feedback_votes wird durch feedback_reactions ersetzt (0 Rows in Prod +-- + lokal vor diesem Commit verifiziert, deshalb destruktiver DROP/CREATE +-- statt Rename+Add-Column). +-- +-- Apply manuell: +-- psql "$DATABASE_URL" -f services/mana-analytics/drizzle/0002_public-community-foundation.sql +-- +-- Idempotent via IF EXISTS / IF NOT EXISTS. + +BEGIN; + +-- 1. Pseudonym + Module-Context + Threading + Reactions auf user_feedback +ALTER TABLE feedback.user_feedback + ADD COLUMN IF NOT EXISTS display_hash text, + ADD COLUMN IF NOT EXISTS display_name text, + ADD COLUMN IF NOT EXISTS module_context text, + ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES feedback.user_feedback(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS reactions jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS score integer NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS feedback_display_hash_idx ON feedback.user_feedback(display_hash); +CREATE INDEX IF NOT EXISTS feedback_module_context_idx ON feedback.user_feedback(module_context); +CREATE INDEX IF NOT EXISTS feedback_parent_id_idx ON feedback.user_feedback(parent_id); +CREATE INDEX IF NOT EXISTS feedback_score_idx ON feedback.user_feedback(score DESC); + +-- 2. feedback_votes durch feedback_reactions ersetzen +DROP TABLE IF EXISTS feedback.feedback_votes; + +CREATE TABLE IF NOT EXISTS feedback.feedback_reactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + feedback_id uuid NOT NULL REFERENCES feedback.user_feedback(id) ON DELETE CASCADE, + user_id text NOT NULL, + emoji text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS feedback_reactions_unique + ON feedback.feedback_reactions(feedback_id, user_id, emoji); +CREATE INDEX IF NOT EXISTS feedback_reactions_feedback_idx + ON feedback.feedback_reactions(feedback_id); + +COMMIT; diff --git a/services/mana-analytics/src/config.ts b/services/mana-analytics/src/config.ts index 41aa08118..85e5387bb 100644 --- a/services/mana-analytics/src/config.ts +++ b/services/mana-analytics/src/config.ts @@ -4,6 +4,12 @@ export interface Config { manaAuthUrl: string; manaLlmUrl: string; serviceKey: string; + /** + * Secret seeded into the per-user display-hash for the public-community + * pseudonym ("Wachsame Eule #4528"). Rotating this re-keys all future + * pseudonyms — existing rows keep the old hash/name. + */ + pseudonymSecret: string; cors: { origins: string[] }; } @@ -11,13 +17,11 @@ export function loadConfig(): Config { const env = (key: string, fallback?: string) => process.env[key] || fallback || ''; return { port: parseInt(env('PORT', '3064'), 10), - 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'), manaLlmUrl: env('MANA_LLM_URL', 'http://localhost:3025'), serviceKey: env('MANA_SERVICE_KEY', 'dev-service-key'), + pseudonymSecret: env('FEEDBACK_PSEUDONYM_SECRET', 'dev-pseudonym-secret'), 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 d1100b443..97a3cb48c 100644 --- a/services/mana-analytics/src/db/schema/feedback.ts +++ b/services/mana-analytics/src/db/schema/feedback.ts @@ -9,6 +9,7 @@ import { index, unique, pgEnum, + type AnyPgColumn, } from 'drizzle-orm/pg-core'; export const feedbackSchema = pgSchema('feedback'); @@ -49,6 +50,26 @@ export const userFeedback = feedbackSchema.table( 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. + // 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(), @@ -57,23 +78,32 @@ export const userFeedback = feedbackSchema.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), }) ); -export const feedbackVotes = feedbackSchema.table( - 'feedback_votes', +// 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) => ({ - feedbackUserUnique: unique('feedback_votes_unique').on(table.feedbackId, table.userId), + uniq: unique('feedback_reactions_unique').on(table.feedbackId, table.userId, table.emoji), + feedbackIdx: index('feedback_reactions_feedback_idx').on(table.feedbackId), }) ); export type Feedback = typeof userFeedback.$inferSelect; -export type FeedbackVote = typeof feedbackVotes.$inferSelect; +export type FeedbackReaction = typeof feedbackReactions.$inferSelect; diff --git a/services/mana-analytics/src/index.ts b/services/mana-analytics/src/index.ts index e8e5fd12b..acc180ab1 100644 --- a/services/mana-analytics/src/index.ts +++ b/services/mana-analytics/src/index.ts @@ -1,8 +1,9 @@ /** - * mana-analytics — Feedback and analytics service + * mana-analytics — Public-Community Feedback Hub * - * Hono + Bun runtime. Extracted from mana-auth. - * Handles: user feedback, voting, AI-powered title generation. + * Hono + Bun runtime. Routes: + * /api/v1/feedback/* — auth-required (jwtAuth via JWKS) + * /api/v1/public/feedback/* — read-only, no auth, redacted output */ import { Hono } from 'hono'; @@ -14,11 +15,12 @@ import { jwtAuth } from './middleware/jwt-auth'; import { FeedbackService } from './services/feedback'; import { healthRoutes } from './routes/health'; import { createFeedbackRoutes } from './routes/feedback'; +import { createPublicFeedbackRoutes } from './routes/public'; const config = loadConfig(); const db = getDb(config.databaseUrl); -const feedbackService = new FeedbackService(db, config.manaLlmUrl); +const feedbackService = new FeedbackService(db, config.manaLlmUrl, config.pseudonymSecret); const app = new Hono(); @@ -27,6 +29,10 @@ app.use('*', cors({ origin: config.cors.origins, credentials: true })); app.route('/health', healthRoutes); +// Public surface: anonymous reads, no JWT required. +app.route('/api/v1/public/feedback', createPublicFeedbackRoutes(feedbackService)); + +// Authenticated surface: submit, react, manage own items, admin. app.use('/api/v1/feedback/*', jwtAuth(config.manaAuthUrl)); app.route('/api/v1/feedback', createFeedbackRoutes(feedbackService)); diff --git a/services/mana-analytics/src/lib/pseudonym.test.ts b/services/mana-analytics/src/lib/pseudonym.test.ts new file mode 100644 index 000000000..6ff8f0733 --- /dev/null +++ b/services/mana-analytics/src/lib/pseudonym.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { createDisplayHash, generateDisplayName, __TEST__ } from './pseudonym'; + +describe('pseudonym', () => { + it('createDisplayHash is deterministic per (userId, secret)', () => { + const a = createDisplayHash('user-123', 'secret'); + const b = createDisplayHash('user-123', 'secret'); + expect(a).toBe(b); + }); + + it('createDisplayHash varies per userId', () => { + const a = createDisplayHash('user-1', 'secret'); + const b = createDisplayHash('user-2', 'secret'); + expect(a).not.toBe(b); + }); + + it('createDisplayHash varies per secret (rotation safety)', () => { + const a = createDisplayHash('user-1', 'secret-old'); + const b = createDisplayHash('user-1', 'secret-new'); + expect(a).not.toBe(b); + }); + + it('generateDisplayName is deterministic per hash', () => { + const hash = createDisplayHash('user-123', 'secret'); + expect(generateDisplayName(hash)).toBe(generateDisplayName(hash)); + }); + + it('generateDisplayName format is "{Adj} {Tier} #{4-digit}"', () => { + const hash = createDisplayHash('user-1', 'secret'); + const name = generateDisplayName(hash); + expect(name).toMatch(/^[A-ZÄÖÜ][a-zäöüß]+ [A-ZÄÖÜ][a-zäöüß]+ #\d{4}$/); + }); + + it('uses adjectives + animals from the curated lists', () => { + const hash = createDisplayHash('user-1', 'secret'); + const [adj, animal] = generateDisplayName(hash).split(' '); + expect(__TEST__.ADJECTIVES).toContain(adj); + expect(__TEST__.ANIMALS).toContain(animal); + }); + + it('produces varied names across many users (no obvious clustering)', () => { + const names = new Set(); + for (let i = 0; i < 1000; i++) { + names.add(generateDisplayName(createDisplayHash(`user-${i}`, 'secret'))); + } + // 1000 users → expect ~1000 unique pseudonyms (collisions allowed but rare). + expect(names.size).toBeGreaterThan(950); + }); +}); diff --git a/services/mana-analytics/src/lib/pseudonym.ts b/services/mana-analytics/src/lib/pseudonym.ts new file mode 100644 index 000000000..c30f34ae8 --- /dev/null +++ b/services/mana-analytics/src/lib/pseudonym.ts @@ -0,0 +1,241 @@ +/** + * Pseudonym generator for the public-community surface. + * + * Same hash-input always yields the same display_name → users see "their" + * pseudonym consistently across submissions, but real userId is never + * exposed. + * + * Generation: + * "{Adjektiv} {Tier} #{HashSuffix}" + * e.g. "Wachsame Eule #4528", "Heimliche Otter #0091" + * + * Naming space: 100 adjectives × 80 animals × 10000 numeric suffixes + * = 80 million combinations. Collision risk irrelevant in practice. + * + * The 4-digit suffix is the last 4 hex characters of the hash interpreted + * as decimal (modulo 10000). Adjective and animal indices are derived + * from disjoint hash slices so they vary independently. + */ + +import { createHash } from 'crypto'; + +const ADJECTIVES = [ + 'Wachsame', + 'Heimliche', + 'Stille', + 'Kühne', + 'Sanfte', + 'Wilde', + 'Listige', + 'Weise', + 'Mutige', + 'Träumerische', + 'Neugierige', + 'Treue', + 'Verspielte', + 'Bedächtige', + 'Flinke', + 'Helle', + 'Dunkle', + 'Goldene', + 'Silberne', + 'Funkelnde', + 'Glühende', + 'Kühle', + 'Warme', + 'Singende', + 'Tanzende', + 'Schweigende', + 'Lauschende', + 'Suchende', + 'Findende', + 'Wandernde', + 'Schwebende', + 'Tauchende', + 'Träumende', + 'Wache', + 'Frohe', + 'Ernste', + 'Leise', + 'Laute', + 'Sanfte', + 'Schnelle', + 'Langsame', + 'Geheime', + 'Offene', + 'Versteckte', + 'Sichtbare', + 'Strahlende', + 'Glänzende', + 'Matte', + 'Helle', + 'Schimmernde', + 'Flackernde', + 'Stetige', + 'Bewegte', + 'Ruhige', + 'Stürmische', + 'Friedliche', + 'Kämpferische', + 'Freundliche', + 'Misstrauische', + 'Vertrauende', + 'Hoffende', + 'Zweifelnde', + 'Glaubende', + 'Fragende', + 'Antwortende', + 'Lernende', + 'Lehrende', + 'Wachsende', + 'Reife', + 'Junge', + 'Alte', + 'Zeitlose', + 'Frische', + 'Müde', + 'Wache', + 'Schlafende', + 'Erwachende', + 'Träumende', + 'Erinnernde', + 'Vergessende', + 'Zählende', + 'Sammelnde', + 'Verschenkende', + 'Bewahrende', + 'Suchende', + 'Wartende', + 'Eilende', + 'Bleibende', + 'Reisende', + 'Heimkehrende', + 'Aufbrechende', + 'Ankommende', + 'Lauschende', + 'Singende', + 'Pfeifende', + 'Summende', + 'Knurrende', + 'Schnurrende', + 'Lachende', + 'Weinende', + 'Schmunzelnde', + 'Staunende', +]; + +const ANIMALS = [ + 'Eule', + 'Otter', + 'Fuchs', + 'Wolf', + 'Bär', + 'Luchs', + 'Adler', + 'Reiher', + 'Kranich', + 'Schwalbe', + 'Lerche', + 'Amsel', + 'Specht', + 'Falke', + 'Habicht', + 'Sperber', + 'Krähe', + 'Rabe', + 'Elster', + 'Häher', + 'Star', + 'Drossel', + 'Meise', + 'Fink', + 'Sperling', + 'Schmetterling', + 'Libelle', + 'Hirsch', + 'Reh', + 'Gämse', + 'Steinbock', + 'Murmeltier', + 'Eichhörnchen', + 'Iltis', + 'Marder', + 'Dachs', + 'Igel', + 'Wiesel', + 'Hermelin', + 'Salamander', + 'Molch', + 'Frosch', + 'Kröte', + 'Eidechse', + 'Schlange', + 'Schildkröte', + 'Karpfen', + 'Hecht', + 'Forelle', + 'Lachs', + 'Stör', + 'Aal', + 'Krebs', + 'Schnecke', + 'Spinne', + 'Käfer', + 'Hummel', + 'Biene', + 'Wespe', + 'Ameise', + 'Grashüpfer', + 'Grille', + 'Heuschrecke', + 'Marienkäfer', + 'Hirschkäfer', + 'Robbe', + 'Seehund', + 'Delfin', + 'Wal', + 'Hai', + 'Möwe', + 'Albatros', + 'Pelikan', + 'Kormoran', + 'Pinguin', + 'Schwan', + 'Gans', + 'Ente', + 'Storch', + 'Ibis', +]; + +function hashToBigInt(hash: string): bigint { + // Strip non-hex chars, take first 16 hex chars (64-bit slice). + const slice = hash.replace(/[^0-9a-f]/gi, '').slice(0, 16); + return BigInt('0x' + slice); +} + +/** + * Deterministically derive a display name from a display-hash. + * + * @param displayHash hex string from createDisplayHash(). + * @returns "{Adjektiv} {Tier} #{NNNN}" — stable across calls for same hash. + */ +export function generateDisplayName(displayHash: string): string { + const big = hashToBigInt(displayHash); + const adj = ADJECTIVES[Number(big % BigInt(ADJECTIVES.length))]; + const animal = ANIMALS[Number((big / BigInt(ADJECTIVES.length)) % BigInt(ANIMALS.length))]; + const suffix = Number((big / BigInt(ADJECTIVES.length * ANIMALS.length)) % 10000n) + .toString() + .padStart(4, '0'); + return `${adj} ${animal} #${suffix}`; +} + +/** + * Derive a non-reversible display hash for a given userId. + * Same userId + same secret always produces the same hash. + */ +export function createDisplayHash(userId: string, secret: string): string { + return createHash('sha256').update(`${userId}:${secret}`).digest('hex'); +} + +// Exported for tests. +export const __TEST__ = { ADJECTIVES, ANIMALS }; diff --git a/services/mana-analytics/src/routes/feedback.ts b/services/mana-analytics/src/routes/feedback.ts index 84d015444..844618835 100644 --- a/services/mana-analytics/src/routes/feedback.ts +++ b/services/mana-analytics/src/routes/feedback.ts @@ -1,34 +1,123 @@ +/** + * Authenticated feedback routes — mounted under /api/v1/feedback. + * + * All routes here require a valid Bearer token (jwtAuth middleware + * applied at the app level in index.ts). Public-read endpoints live + * separately under /api/v1/public/feedback (see ./public.ts). + */ + import { Hono } from 'hono'; import type { FeedbackService } from '../services/feedback'; +import { ALLOWED_EMOJIS } from '../services/feedback'; import type { AuthUser } from '../middleware/jwt-auth'; +import { BadRequestError, ForbiddenError } from '../lib/errors'; + +const FOUNDER_ROLES = new Set(['founder', 'admin']); export function createFeedbackRoutes(feedbackService: FeedbackService) { - return new Hono<{ Variables: { user: AuthUser } }>() - .post('/', async (c) => { - const user = c.get('user'); - const body = await c.req.json(); - return c.json(await feedbackService.createFeedback(user.userId, body), 201); - }) - .get('/public', async (c) => { - const appId = c.req.query('appId'); - const limit = parseInt(c.req.query('limit') || '50', 10); - const offset = parseInt(c.req.query('offset') || '0', 10); - return c.json(await feedbackService.getPublicFeedback(appId, limit, offset)); - }) - .get('/me', async (c) => { - const user = c.get('user'); - return c.json(await feedbackService.getMyFeedback(user.userId)); - }) - .post('/:id/vote', async (c) => { - const user = c.get('user'); - return c.json(await feedbackService.vote(c.req.param('id'), user.userId)); - }) - .delete('/:id/vote', async (c) => { - const user = c.get('user'); - return c.json(await feedbackService.unvote(c.req.param('id'), user.userId)); - }) - .delete('/:id', async (c) => { - const user = c.get('user'); - return c.json(await feedbackService.deleteFeedback(c.req.param('id'), user.userId)); + const r = new Hono<{ Variables: { user: AuthUser } }>(); + + // ── User-facing ─────────────────────────────────────────────────── + + r.post('/', async (c) => { + const user = c.get('user'); + const body = await c.req.json(); + const item = await feedbackService.createFeedback(user.userId, body); + return c.json(item, 201); + }); + + /** Auth-required public feed — same as /public/feed but additionally + * enriches each item with the requesting user's reaction state. */ + r.get('/public', async (c) => { + const user = c.get('user'); + const appId = c.req.query('appId') || undefined; + const moduleContext = c.req.query('moduleContext') || undefined; + const category = c.req.query('category') || undefined; + const status = c.req.query('status') || undefined; + const limit = parseInt(c.req.query('limit') || '50', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + + const items = await feedbackService.getPublicFeed({ + appId, + moduleContext, + category, + status, + limit, + offset, }); + + const enriched = await Promise.all( + items.map(async (item) => ({ + ...item, + myReactions: await feedbackService.getMyReactionsFor(item.id, user.userId), + })) + ); + + return c.json({ items: enriched }); + }); + + r.get('/me', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.getMyFeedback(user.userId)); + }); + + r.get('/:id/replies', async (c) => { + return c.json(await feedbackService.getReplies(c.req.param('id'))); + }); + + // ── Reactions ───────────────────────────────────────────────────── + + r.post('/:id/react', async (c) => { + const user = c.get('user'); + const { emoji } = await c.req.json<{ emoji: string }>(); + if (!emoji || !ALLOWED_EMOJIS.includes(emoji)) { + throw new BadRequestError(`emoji must be one of: ${ALLOWED_EMOJIS.join(' ')}`); + } + const result = await feedbackService.toggleReaction(c.req.param('id'), user.userId, emoji); + return c.json(result); + }); + + r.delete('/:id', async (c) => { + const user = c.get('user'); + return c.json(await feedbackService.deleteFeedback(c.req.param('id'), user.userId)); + }); + + // ── Admin (founder/admin role) ──────────────────────────────────── + + r.get('/admin', async (c) => { + const user = c.get('user'); + if (!FOUNDER_ROLES.has(user.role)) throw new ForbiddenError('Founder/admin role required'); + + const appId = c.req.query('appId') || undefined; + const category = c.req.query('category') || undefined; + const status = c.req.query('status') || undefined; + const moduleContext = c.req.query('moduleContext') || undefined; + const limit = parseInt(c.req.query('limit') || '100', 10); + const offset = parseInt(c.req.query('offset') || '0', 10); + + const items = await feedbackService.adminListAll({ + appId, + category, + status, + moduleContext, + limit, + offset, + }); + return c.json({ items }); + }); + + r.patch('/admin/:id', async (c) => { + const user = c.get('user'); + if (!FOUNDER_ROLES.has(user.role)) throw new ForbiddenError('Founder/admin role required'); + + const patch = await c.req.json<{ + status?: string; + adminResponse?: string; + isPublic?: boolean; + }>(); + const updated = await feedbackService.adminUpdate(c.req.param('id'), patch); + return c.json(updated); + }); + + return r; } diff --git a/services/mana-analytics/src/routes/public.ts b/services/mana-analytics/src/routes/public.ts new file mode 100644 index 000000000..f1b57601c --- /dev/null +++ b/services/mana-analytics/src/routes/public.ts @@ -0,0 +1,44 @@ +/** + * Public, anonymous feedback routes — mounted under /api/v1/public/feedback. + * + * Distinct from /api/v1/feedback/* because the jwtAuth middleware is + * scoped to that prefix only — anything under /api/v1/public/* skips + * auth entirely. Output is fully redacted (display_name only, no + * userId / displayHash / deviceInfo); reaction state is not enriched + * because there is no caller identity. + */ + +import { Hono } from 'hono'; +import type { FeedbackService } from '../services/feedback'; + +export function createPublicFeedbackRoutes(feedbackService: FeedbackService) { + const r = new Hono(); + + r.get('/feed', async (c) => { + const appId = c.req.query('appId') || undefined; + const moduleContext = c.req.query('moduleContext') || undefined; + const category = c.req.query('category') || undefined; + const status = c.req.query('status') || undefined; + const limit = Math.min(parseInt(c.req.query('limit') || '50', 10), 200); + const offset = parseInt(c.req.query('offset') || '0', 10); + + const items = await feedbackService.getPublicFeed({ + appId, + moduleContext, + category, + status, + limit, + offset, + }); + return c.json({ items }); + }); + + r.get('/:id', async (c) => { + const item = await feedbackService.getPublicItem(c.req.param('id')); + if (!item) return c.json({ error: 'Not found' }, 404); + const replies = await feedbackService.getReplies(item.id); + return c.json({ item, replies }); + }); + + return r; +} diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts index 6f18a528f..0d15a70cb 100644 --- a/services/mana-analytics/src/services/feedback.ts +++ b/services/mana-analytics/src/services/feedback.ts @@ -1,18 +1,64 @@ /** - * Feedback Service — User feedback CRUD with voting + * Feedback Service — Public-Community-Hub backend. + * + * Public reads are anonymous: callers receive the persistent pseudonym + * (display_name) but never the underlying userId. Writes (create / react / + * delete) require auth at the route layer; the service trusts the + * userId argument. + * + * Reactions follow the Slack pattern: a user can stack multiple emojis + * on the same item. Aggregated counts are mirrored to + * `user_feedback.reactions` so the public feed sorts on a cached score + * column. */ -import { eq, and, desc, sql } from 'drizzle-orm'; -import { userFeedback, feedbackVotes } from '../db/schema/feedback'; +import { eq, and, desc, sql, isNull } from 'drizzle-orm'; +import { userFeedback, feedbackReactions } from '../db/schema/feedback'; import type { Database } from '../db/connection'; -import { NotFoundError } from '../lib/errors'; +import { NotFoundError, BadRequestError } from '../lib/errors'; +import { createDisplayHash, generateDisplayName } from '../lib/pseudonym'; + +/** + * Allowed reaction emojis with sort-score weights. + * Add a new emoji here to make it submittable. + */ +const REACTION_WEIGHTS: Record = { + '👍': 1, + '❤️': 1, + '🚀': 2, + '🤔': 0, + '🎉': 1, +}; + +const ALLOWED_EMOJIS = Object.keys(REACTION_WEIGHTS); + +export type PublicFeedbackItem = { + id: string; + appId: string; + title: string | null; + feedbackText: string; + category: string; + status: string; + moduleContext: string | null; + parentId: string | null; + displayName: string; + reactions: Record; + score: number; + adminResponse: string | null; + createdAt: Date; + updatedAt: Date; +}; export class FeedbackService { constructor( private db: Database, - private llmUrl: string + private llmUrl: string, + /** Secret used to derive non-reversible per-user display hashes. */ + private pseudonymSecret: string ) {} + // ── Submission ──────────────────────────────────────────────────── + async createFeedback( userId: string, data: { @@ -21,13 +67,16 @@ export class FeedbackService { category?: string; title?: string; isPublic?: boolean; + moduleContext?: string; + parentId?: string; deviceInfo?: Record; } ) { let title = data.title; - // Auto-generate title via LLM if not provided - if (!title && this.llmUrl) { + // Auto-title via mana-llm only for top-level items; replies inherit + // context from parent and don't need their own title. + if (!title && !data.parentId && this.llmUrl) { try { title = await this.generateTitle(data.feedbackText); } catch { @@ -35,6 +84,9 @@ export class FeedbackService { } } + const displayHash = createDisplayHash(userId, this.pseudonymSecret); + const displayName = generateDisplayName(displayHash); + const [feedback] = await this.db .insert(userFeedback) .values({ @@ -43,10 +95,11 @@ export class FeedbackService { title: title || data.feedbackText.slice(0, 80), feedbackText: data.feedbackText, category: (data.category as any) || 'other', - // Honor explicit isPublic from caller; otherwise let the column - // default (true) apply. Private intake categories like - // 'onboarding-wish' should pass `false`. ...(typeof data.isPublic === 'boolean' ? { isPublic: data.isPublic } : {}), + moduleContext: data.moduleContext ?? null, + parentId: data.parentId ?? null, + displayHash, + displayName, deviceInfo: data.deviceInfo, }) .returning(); @@ -54,18 +107,65 @@ export class FeedbackService { return feedback; } - async getPublicFeedback(appId?: string, limit = 50, offset = 0) { - let query = this.db + // ── Public reads (no auth) ──────────────────────────────────────── + + /** + * Public feed: top-level items only (parent_id IS NULL), is_public=true. + * Sorted by cached score desc, then recency. Output is redacted — + * userId / displayHash / deviceInfo never leave the service. + */ + async getPublicFeed( + opts: { + appId?: string; + moduleContext?: string; + category?: string; + status?: string; + limit?: number; + offset?: number; + } = {} + ): Promise { + const { appId, moduleContext, category, status, limit = 50, offset = 0 } = opts; + + const conditions = [eq(userFeedback.isPublic, true), isNull(userFeedback.parentId)]; + if (appId) conditions.push(eq(userFeedback.appId, appId)); + if (moduleContext) conditions.push(eq(userFeedback.moduleContext, moduleContext)); + if (category) conditions.push(eq(userFeedback.category, category as any)); + if (status) conditions.push(eq(userFeedback.status, status as any)); + + const rows = await this.db .select() .from(userFeedback) - .where(eq(userFeedback.isPublic, true)) - .orderBy(desc(userFeedback.voteCount)) + .where(and(...conditions)) + .orderBy(desc(userFeedback.score), desc(userFeedback.createdAt)) .limit(limit) .offset(offset); - return query; + return rows.map(redact); } + /** Replies for a single parent item (1-level threading). */ + async getReplies(parentId: string): Promise { + const rows = await this.db + .select() + .from(userFeedback) + .where(and(eq(userFeedback.parentId, parentId), eq(userFeedback.isPublic, true))) + .orderBy(userFeedback.createdAt); + return rows.map(redact); + } + + /** Single public item by id. Returns null if not found / not public. */ + async getPublicItem(id: string): Promise { + const [row] = await this.db + .select() + .from(userFeedback) + .where(and(eq(userFeedback.id, id), eq(userFeedback.isPublic, true))) + .limit(1); + return row ? redact(row) : null; + } + + // ── Authenticated reads ─────────────────────────────────────────── + + /** Items the user has authored (across all isPublic states). */ async getMyFeedback(userId: string) { return this.db .select() @@ -74,30 +174,96 @@ export class FeedbackService { .orderBy(desc(userFeedback.createdAt)); } - async vote(feedbackId: string, userId: string) { - await this.db.insert(feedbackVotes).values({ feedbackId, userId }).onConflictDoNothing(); - await this.db - .update(userFeedback) - .set({ voteCount: sql`${userFeedback.voteCount} + 1` }) - .where(eq(userFeedback.id, feedbackId)); - return { success: true }; + /** Map of emoji → boolean for the requesting user on a feedback item. */ + async getMyReactionsFor(feedbackId: string, userId: string): Promise { + const rows = await this.db + .select({ emoji: feedbackReactions.emoji }) + .from(feedbackReactions) + .where( + and(eq(feedbackReactions.feedbackId, feedbackId), eq(feedbackReactions.userId, userId)) + ); + return rows.map((r) => r.emoji); } - async unvote(feedbackId: string, userId: string) { - const result = await this.db - .delete(feedbackVotes) - .where(and(eq(feedbackVotes.feedbackId, feedbackId), eq(feedbackVotes.userId, userId))) + // ── Reactions ───────────────────────────────────────────────────── + + /** + * Toggle a single emoji reaction for (feedbackId, userId). + * Returns the updated reaction-counter map and score. + */ + async toggleReaction( + feedbackId: string, + userId: string, + emoji: string + ): Promise<{ reactions: Record; score: number; userHasReacted: boolean }> { + if (!ALLOWED_EMOJIS.includes(emoji)) { + throw new BadRequestError(`Unsupported emoji: ${emoji}`); + } + + // Ensure target item exists. + const [item] = await this.db + .select({ id: userFeedback.id }) + .from(userFeedback) + .where(eq(userFeedback.id, feedbackId)) + .limit(1); + if (!item) throw new NotFoundError('Feedback not found'); + + // Try to insert (react). If conflicting → user already reacted, so unreact. + const inserted = await this.db + .insert(feedbackReactions) + .values({ feedbackId, userId, emoji }) + .onConflictDoNothing() .returning(); - if (result.length > 0) { + let userHasReacted: boolean; + if (inserted.length === 0) { + // Already reacted → remove the row (unreact). await this.db - .update(userFeedback) - .set({ voteCount: sql`GREATEST(${userFeedback.voteCount} - 1, 0)` }) - .where(eq(userFeedback.id, feedbackId)); + .delete(feedbackReactions) + .where( + and( + eq(feedbackReactions.feedbackId, feedbackId), + eq(feedbackReactions.userId, userId), + eq(feedbackReactions.emoji, emoji) + ) + ); + userHasReacted = false; + } else { + userHasReacted = true; } - return { success: true }; + + // Recompute aggregated reactions + score for this item. + const aggregated = await this.recomputeReactions(feedbackId); + return { ...aggregated, userHasReacted }; } + /** Recomputes user_feedback.reactions + score from feedback_reactions. */ + private async recomputeReactions( + feedbackId: string + ): Promise<{ reactions: Record; score: number }> { + const rows = await this.db + .select({ emoji: feedbackReactions.emoji, count: sql`count(*)::int` }) + .from(feedbackReactions) + .where(eq(feedbackReactions.feedbackId, feedbackId)) + .groupBy(feedbackReactions.emoji); + + const reactions: Record = {}; + let score = 0; + for (const row of rows) { + reactions[row.emoji] = row.count; + score += (REACTION_WEIGHTS[row.emoji] ?? 0) * row.count; + } + + await this.db + .update(userFeedback) + .set({ reactions, score, updatedAt: new Date() }) + .where(eq(userFeedback.id, feedbackId)); + + return { reactions, score }; + } + + // ── Mutations ───────────────────────────────────────────────────── + async deleteFeedback(feedbackId: string, userId: string) { const result = await this.db .delete(userFeedback) @@ -107,6 +273,54 @@ export class FeedbackService { return { success: true }; } + // ── Admin (founder-tier-gated at route layer) ───────────────────── + + async adminListAll( + opts: { + appId?: string; + category?: string; + status?: string; + moduleContext?: string; + limit?: number; + offset?: number; + } = {} + ) { + const { appId, category, status, moduleContext, limit = 100, offset = 0 } = opts; + const conditions = []; + if (appId) conditions.push(eq(userFeedback.appId, appId)); + if (category) conditions.push(eq(userFeedback.category, category as any)); + if (status) conditions.push(eq(userFeedback.status, status as any)); + if (moduleContext) conditions.push(eq(userFeedback.moduleContext, moduleContext)); + + return this.db + .select() + .from(userFeedback) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(userFeedback.createdAt)) + .limit(limit) + .offset(offset); + } + + async adminUpdate( + feedbackId: string, + patch: { status?: string; adminResponse?: string; isPublic?: boolean } + ) { + const update: Record = { updatedAt: new Date() }; + if (patch.status !== undefined) update.status = patch.status; + if (patch.adminResponse !== undefined) update.adminResponse = patch.adminResponse; + if (patch.isPublic !== undefined) update.isPublic = patch.isPublic; + + const [row] = await this.db + .update(userFeedback) + .set(update) + .where(eq(userFeedback.id, feedbackId)) + .returning(); + if (!row) throw new NotFoundError('Feedback not found'); + return row; + } + + // ── LLM helpers ─────────────────────────────────────────────────── + private async generateTitle(text: string): Promise { const res = await fetch(`${this.llmUrl}/api/v1/chat/completions`, { method: 'POST', @@ -129,3 +343,25 @@ export class FeedbackService { return data.choices?.[0]?.message?.content?.trim() || text.slice(0, 80); } } + +/** Strips userId / displayHash / deviceInfo from a row. */ +function redact(row: typeof userFeedback.$inferSelect): PublicFeedbackItem { + return { + id: row.id, + appId: row.appId, + title: row.title, + feedbackText: row.feedbackText, + category: row.category, + status: row.status, + moduleContext: row.moduleContext, + parentId: row.parentId, + displayName: row.displayName ?? 'Anonym', + reactions: (row.reactions as Record) ?? {}, + score: row.score, + adminResponse: row.adminResponse, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export { ALLOWED_EMOJIS, REACTION_WEIGHTS };