feat(mana-analytics): pseudonym + reactions + public feed + admin

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-27 00:00:35 +02:00
parent fd11481d94
commit 8b0a943e71
9 changed files with 820 additions and 70 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, number> = {
'👍': 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<string, number>;
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<string, unknown>;
}
) {
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<PublicFeedbackItem[]> {
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<PublicFeedbackItem[]> {
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<PublicFeedbackItem | null> {
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<string[]> {
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<string, number>; 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<string, number>; score: number }> {
const rows = await this.db
.select({ emoji: feedbackReactions.emoji, count: sql<number>`count(*)::int` })
.from(feedbackReactions)
.where(eq(feedbackReactions.feedbackId, feedbackId))
.groupBy(feedbackReactions.emoji);
const reactions: Record<string, number> = {};
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<string, unknown> = { 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<string> {
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<string, number>) ?? {},
score: row.score,
adminResponse: row.adminResponse,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export { ALLOWED_EMOJIS, REACTION_WEIGHTS };