From ee5bb2871cdc7b94e00495205dd4c005a74faebe Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 27 Apr 2026 15:15:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(community):=20Phase=203.C=20=E2=80=94=20Id?= =?UTF-8?q?entit=C3=A4t=20(Avatar=20+=20Klarname-Toggle=20+=20Karma=20+=20?= =?UTF-8?q?Eulen-Profil)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macht aus den Pseudonymen echte Charaktere ohne Klarnamen-Zwang. Pixel-Identicon-Avatar (3.C.2): - generateAvatarSvg(displayHash) — pure-function, deterministisch. 5×5 left-mirrored Identicon mit HSL-Foreground/Background aus dem Hash. Inline-SVG, kein Storage, kein img-load-Flicker. - Component im Package, in ItemCard neben dem Pseudonym. Klarname-Toggle (3.C.1): - auth.users + community_show_real_name boolean (default off, opt-in). - PATCH /api/v1/me/profile akzeptiert communityShowRealName. - mana-analytics LEFT JOINs auth.users → bei opt-in liefert auth- required /public + /me/reacted Endpoints zusätzlich realName. - Anonymous /api/v1/public/feedback/* zeigt realName NIE — auch nicht wenn opted-in. Public-Mirror bleibt für SEO + Privacy safe. - Migration 008_community_identity.sql lokal + prod eingespielt. Karma-System (3.C.3): - auth.users + community_karma int. toggleReaction increment/decrement am Author-User (Self-Reactions zählen nicht — kein Self-Farming). - KARMA_THRESHOLDS + tierFromKarma() im Package: Bronze (0-9) / Silver (10-49) / Gold (50-199) / Platin (200+). - ItemCard zeigt Tier-Dot neben dem Pseudonym, Title-Tooltip mit Karma-Zahl. Floor-clamped at 0. Eulen-Profil (3.C.4): - GET /api/v1/public/feedback/eule/{hash} — alle public-Posts dieser Eule + aggregiertes Karma. SHA256-Format-Validation. - /community/eule/[hash] Public-SSR-Route mit Avatar-Hero, Tier-Badge, Karma-Counter, Post-Liste. Author-Klick im ItemCard navigiert hin. - publicFeedbackService.getEulenProfile() im Package. PublicFeedbackItem erweitert um displayHash (public Pseudonym-ID, SHA256 ist one-way → safe to expose) + karma + optional realName. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../community/components/ItemCard.svelte | 50 +++- .../community/eule/[hash]/+page.server.ts | 21 ++ .../routes/community/eule/[hash]/+page.svelte | 261 ++++++++++++++++++ packages/feedback/src/EulenAvatar.svelte | 58 ++++ packages/feedback/src/api.ts | 7 + packages/feedback/src/avatar.ts | 97 +++++++ .../src/createPublicFeedbackService.ts | 13 +- packages/feedback/src/feedback.ts | 36 +++ packages/feedback/src/index.ts | 9 + .../src/db/schema/auth-users.ts | 22 ++ .../mana-analytics/src/routes/feedback.ts | 3 +- services/mana-analytics/src/routes/public.ts | 9 + .../mana-analytics/src/services/feedback.ts | 143 +++++++++- .../mana-auth/sql/008_community_identity.sql | 20 ++ services/mana-auth/src/db/schema/auth.ts | 8 + services/mana-auth/src/routes/me.ts | 26 +- 16 files changed, 760 insertions(+), 23 deletions(-) create mode 100644 apps/mana/apps/web/src/routes/community/eule/[hash]/+page.server.ts create mode 100644 apps/mana/apps/web/src/routes/community/eule/[hash]/+page.svelte create mode 100644 packages/feedback/src/EulenAvatar.svelte create mode 100644 packages/feedback/src/avatar.ts create mode 100644 services/mana-analytics/src/db/schema/auth-users.ts create mode 100644 services/mana-auth/sql/008_community_identity.sql diff --git a/apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte b/apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte index 67d64ec56..3deb9b497 100644 --- a/apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte +++ b/apps/mana/apps/web/src/lib/modules/community/components/ItemCard.svelte @@ -6,8 +6,11 @@ + + + {data.displayName ?? 'Eule'} — Mana Community + + + +
+ ← Zurück zum Feed + +
+ +
+

{data.displayName ?? 'Anonym'}

+
+ + + {tierCfg.label}-Eule + + {data.karma} Karma + {data.items.length} Wünsche +
+

+ Alle öffentlichen Beiträge dieser Eule. Anonym, aber konsistent — derselbe Mensch dahinter, + derselbe Avatar. +

+
+
+ + {#if data.items.length === 0} +
Diese Eule hat noch nichts gepostet.
+ {:else} +
+ {#each data.items as item (item.id)} + {@const cfg = statusOf(item.status)} + + + +
goToItem(item.id)}> +
+ {#if cfg} + + {cfg.label} + + {/if} + {#if item.moduleContext} + {item.moduleContext} + {/if} + {fmtDate(item.createdAt)} +
+ {#if item.title} +

{item.title}

+ {/if} +

{item.feedbackText}

+
+ {/each} +
+ {/if} +
+ + diff --git a/packages/feedback/src/EulenAvatar.svelte b/packages/feedback/src/EulenAvatar.svelte new file mode 100644 index 000000000..22f81bdf6 --- /dev/null +++ b/packages/feedback/src/EulenAvatar.svelte @@ -0,0 +1,58 @@ + + + +{#if svg} + + {@html svg} + +{:else} + +{/if} + + diff --git a/packages/feedback/src/api.ts b/packages/feedback/src/api.ts index e978b827a..34d16c764 100644 --- a/packages/feedback/src/api.ts +++ b/packages/feedback/src/api.ts @@ -60,6 +60,13 @@ export interface PublicItemResponse { replies: PublicFeedbackItem[]; } +export interface EulenProfileResponse { + displayHash: string; + displayName: string | null; + karma: number; + items: PublicFeedbackItem[]; +} + export interface ReactionResponse { reactions: Partial>; score: number; diff --git a/packages/feedback/src/avatar.ts b/packages/feedback/src/avatar.ts new file mode 100644 index 000000000..b6db67cca --- /dev/null +++ b/packages/feedback/src/avatar.ts @@ -0,0 +1,97 @@ +/** + * Pixel-Identicon avatar generator — pure function, no storage. + * + * Derives a deterministic 5×5 left-mirrored pixel grid plus an HSL + * color from a `display_hash`, returns a self-contained SVG string. + * Same hash → same avatar, every time, on every device. + * + * Hash byte layout (we use the first 18 bytes / 36 hex chars): + * bytes[0] → hue (0–360°) + * bytes[1] → saturation (50–90%) + * bytes[2] → lightness (35–60%) + * bytes[3..17] → 15 cell on/off bits (3 cols × 5 rows, mirrored) + * + * The 5×5 grid is generated by computing the left 3 columns from the + * hash bits, then mirroring columns 0+1 onto 4+3 so the silhouette is + * always symmetric — the trick that makes Identicons feel like real + * faces/creatures even though they're random pixels. + */ + +interface AvatarRendering { + cells: boolean[][]; // 5 rows × 5 cols + fg: string; + bg: string; +} + +const GRID = 5; + +function hexToBytes(hex: string): number[] { + const cleaned = hex.replace(/[^0-9a-f]/gi, ''); + const bytes: number[] = []; + for (let i = 0; i < cleaned.length; i += 2) { + bytes.push(parseInt(cleaned.slice(i, i + 2), 16) || 0); + } + return bytes; +} + +function rendering(displayHash: string): AvatarRendering { + const bytes = hexToBytes(displayHash); + if (bytes.length < 18) { + // Pad short hashes — shouldn't happen with SHA256 (32 bytes), but + // defensive so the function never throws on a malformed input. + while (bytes.length < 18) bytes.push(0); + } + + const hue = (bytes[0] / 255) * 360; + const sat = 50 + (bytes[1] / 255) * 40; + const light = 35 + (bytes[2] / 255) * 25; + const fg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, ${light.toFixed(0)}%)`; + const bg = `hsl(${hue.toFixed(0)}, ${sat.toFixed(0)}%, 92%)`; + + const cells: boolean[][] = Array.from({ length: GRID }, () => + Array.from({ length: GRID }, () => false) + ); + let bitIdx = 0; + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < 3; col++) { + const byteIdx = 3 + Math.floor(bitIdx / 8); + const bit = (bytes[byteIdx] >> (bitIdx % 8)) & 1; + const on = bit === 1; + cells[row][col] = on; + // Mirror cols 0+1 onto 4+3. Col 2 is the spine. + if (col < 2) cells[row][GRID - 1 - col] = on; + bitIdx++; + } + } + return { cells, fg, bg }; +} + +/** + * Render the avatar as an inline SVG string. Default 64×64 viewport. + * Width/height attributes are intentionally omitted so callers can + * size via CSS (`.avatar { width: 32px; height: 32px }` etc.). + */ +export function generateAvatarSvg(displayHash: string): string { + const { cells, fg, bg } = rendering(displayHash); + const rectSize = 100 / GRID; + + let pixels = ''; + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < GRID; col++) { + if (!cells[row][col]) continue; + const x = (col * rectSize).toFixed(2); + const y = (row * rectSize).toFixed(2); + pixels += ``; + } + } + + return ( + `` + + `` + + `${pixels}` + + `` + ); +} + +/** Exported for tests. */ +export const __TEST__ = { rendering, hexToBytes, GRID }; diff --git a/packages/feedback/src/createPublicFeedbackService.ts b/packages/feedback/src/createPublicFeedbackService.ts index e3fa896ab..222210426 100644 --- a/packages/feedback/src/createPublicFeedbackService.ts +++ b/packages/feedback/src/createPublicFeedbackService.ts @@ -6,7 +6,12 @@ * `createFeedbackService()` instead and pass a getAuthToken callback. */ -import type { PublicFeedListResponse, PublicItemResponse, FeedbackQueryParams } from './api'; +import type { + PublicFeedListResponse, + PublicItemResponse, + EulenProfileResponse, + FeedbackQueryParams, +} from './api'; import type { PublicFeedbackItem } from './feedback'; import type { PublicFeedbackServiceConfig } from './types'; @@ -42,7 +47,11 @@ export function createPublicFeedbackService(config: PublicFeedbackServiceConfig) return fetchPublic(`${publicEndpoint}/${id}`); } - return { getFeed, getItem }; + async function getEulenProfile(displayHash: string): Promise { + return fetchPublic(`${publicEndpoint}/eule/${displayHash}`); + } + + return { getFeed, getItem, getEulenProfile }; } export type PublicFeedbackService = ReturnType; diff --git a/packages/feedback/src/feedback.ts b/packages/feedback/src/feedback.ts index f891c7c98..27c9f16dd 100644 --- a/packages/feedback/src/feedback.ts +++ b/packages/feedback/src/feedback.ts @@ -54,6 +54,9 @@ export interface PublicFeedbackItem { status: FeedbackStatus; moduleContext: string | null; parentId: string | null; + /** Pseudonym-ID — non-reversible SHA256(userId+secret). Stable + * across sessions, used for avatar generation and Eulen-Profil-URLs. */ + displayHash: string; displayName: string; reactions: Partial>; score: number; @@ -62,6 +65,11 @@ export interface PublicFeedbackItem { updatedAt: string; /** Auth-only: which emojis the requesting user has reacted with. */ myReactions?: string[]; + /** Auth-only + opt-in: post-author's real name when they enabled + * the Klarname-Toggle. Anonymous public-mirror NEVER includes this. */ + realName?: string; + /** Author's community karma — public, drives the tier-badge. */ + karma?: number; } /** @@ -128,6 +136,34 @@ export const FEEDBACK_CATEGORY_LABELS: Record = { other: 'Sonstiges', }; +/** + * Karma → Tier-Mapping. Bronze ist Default für jeden, Tier wird sichtbar + * neben dem Pseudonym in ItemCard. + */ +export type KarmaTier = 'bronze' | 'silver' | 'gold' | 'platinum'; + +export const KARMA_THRESHOLDS = { + bronze: 0, + silver: 10, + gold: 50, + platinum: 200, +} as const satisfies Record; + +export const KARMA_TIER_CONFIG: Record = + { + bronze: { label: 'Bronze', emoji: '🦉', color: '#a16207' }, + silver: { label: 'Silver', emoji: '🦉', color: '#737373' }, + gold: { label: 'Gold', emoji: '🦉', color: '#d97706' }, + platinum: { label: 'Platin', emoji: '🦉', color: '#7c3aed' }, + }; + +export function tierFromKarma(karma: number): KarmaTier { + if (karma >= KARMA_THRESHOLDS.platinum) return 'platinum'; + if (karma >= KARMA_THRESHOLDS.gold) return 'gold'; + if (karma >= KARMA_THRESHOLDS.silver) return 'silver'; + return 'bronze'; +} + export const FEEDBACK_STATUS_CONFIG: Record< FeedbackStatus, { label: string; color: string; icon: string } diff --git a/packages/feedback/src/index.ts b/packages/feedback/src/index.ts index 9061861b0..2fbb2d5a9 100644 --- a/packages/feedback/src/index.ts +++ b/packages/feedback/src/index.ts @@ -15,12 +15,16 @@ export { type Feedback, type FeedbackNotification, type NotificationKind, + type KarmaTier, type PublicFeedbackItem, type ReactionEmoji, REACTION_EMOJIS, REACTION_LABELS, FEEDBACK_CATEGORY_LABELS, FEEDBACK_STATUS_CONFIG, + KARMA_THRESHOLDS, + KARMA_TIER_CONFIG, + tierFromKarma, } from './feedback'; export { @@ -30,6 +34,7 @@ export { type FeedbackListResponse, type PublicFeedListResponse, type PublicItemResponse, + type EulenProfileResponse, type ReactionResponse, type AdminPatchInput, type ReactInput, @@ -43,5 +48,9 @@ export { } from './createPublicFeedbackService'; export type { FeedbackServiceConfig, PublicFeedbackServiceConfig } from './types'; +// Avatar +export { generateAvatarSvg } from './avatar'; + // UI Components export { default as ReactionBar } from './ReactionBar.svelte'; +export { default as EulenAvatar } from './EulenAvatar.svelte'; diff --git a/services/mana-analytics/src/db/schema/auth-users.ts b/services/mana-analytics/src/db/schema/auth-users.ts new file mode 100644 index 000000000..1be4ac984 --- /dev/null +++ b/services/mana-analytics/src/db/schema/auth-users.ts @@ -0,0 +1,22 @@ +/** + * Read-only cross-schema view of auth.users for the public-community + * hub. mana-auth owns the table; we JOIN it from mana-analytics to + * enrich feed responses with the post-author's real-name opt-in and + * karma score. We never INSERT/UPDATE/DELETE here — that's + * mana-auth's job. + * + * The full auth.users schema is defined in + * services/mana-auth/src/db/schema/auth.ts; this file declares just + * the columns mana-analytics actually reads. + */ + +import { pgSchema, text, boolean, integer } from 'drizzle-orm/pg-core'; + +const authSchema = pgSchema('auth'); + +export const authUsers = authSchema.table('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + communityShowRealName: boolean('community_show_real_name').default(false).notNull(), + communityKarma: integer('community_karma').default(0).notNull(), +}); diff --git a/services/mana-analytics/src/routes/feedback.ts b/services/mana-analytics/src/routes/feedback.ts index 0012d4be6..1767bc832 100644 --- a/services/mana-analytics/src/routes/feedback.ts +++ b/services/mana-analytics/src/routes/feedback.ts @@ -44,6 +44,7 @@ export function createFeedbackRoutes(feedbackService: FeedbackService) { status, limit, offset, + includeRealName: true, }); const enriched = await Promise.all( @@ -87,7 +88,7 @@ export function createFeedbackRoutes(feedbackService: FeedbackService) { }); r.get('/:id/replies', async (c) => { - return c.json(await feedbackService.getReplies(c.req.param('id'))); + return c.json(await feedbackService.getReplies(c.req.param('id'), { includeRealName: true })); }); // ── Reactions ───────────────────────────────────────────────────── diff --git a/services/mana-analytics/src/routes/public.ts b/services/mana-analytics/src/routes/public.ts index f1b57601c..1d607edde 100644 --- a/services/mana-analytics/src/routes/public.ts +++ b/services/mana-analytics/src/routes/public.ts @@ -33,6 +33,15 @@ export function createPublicFeedbackRoutes(feedbackService: FeedbackService) { return c.json({ items }); }); + r.get('/eule/:hash', async (c) => { + const hash = c.req.param('hash'); + // Display-hashes are 64-char hex (SHA256) — bail early on garbage. + if (!/^[0-9a-f]{32,64}$/i.test(hash)) { + return c.json({ error: 'invalid display_hash' }, 400); + } + return c.json(await feedbackService.getEulenProfile(hash)); + }); + r.get('/:id', async (c) => { const item = await feedbackService.getPublicItem(c.req.param('id')); if (!item) return c.json({ error: 'Not found' }, 404); diff --git a/services/mana-analytics/src/services/feedback.ts b/services/mana-analytics/src/services/feedback.ts index e30f246f3..dbced6ffd 100644 --- a/services/mana-analytics/src/services/feedback.ts +++ b/services/mana-analytics/src/services/feedback.ts @@ -19,6 +19,7 @@ import { feedbackGrantLog, feedbackNotifications, } from '../db/schema/feedback'; +import { authUsers } from '../db/schema/auth-users'; import type { Database } from '../db/connection'; import { NotFoundError, BadRequestError } from '../lib/errors'; import { createDisplayHash, generateDisplayName } from '../lib/pseudonym'; @@ -65,14 +66,32 @@ export type PublicFeedbackItem = { status: string; moduleContext: string | null; parentId: string | null; + /** Public Pseudonym ID — SHA256 of userId+secret, one-way and + * safe to expose. Used for avatar generation and Eulen-Profil-URLs. */ + displayHash: string; displayName: string; reactions: Record; score: number; adminResponse: string | null; createdAt: Date; updatedAt: Date; + /** Author's community karma (public, drives tier-badge). */ + karma: number; + /** Real name, only present when: + * - the post-author opted in via communityShowRealName=true, AND + * - the response is going to an authenticated caller (the + * anonymous /public endpoint always strips this). + */ + realName?: string; }; +type FeedbackRow = typeof userFeedback.$inferSelect; +type AuthUserRow = { + name: string; + communityShowRealName: boolean; + communityKarma: number; +} | null; + export class FeedbackService { constructor( private db: Database, @@ -213,9 +232,18 @@ export class FeedbackService { status?: string; limit?: number; offset?: number; + includeRealName?: boolean; } = {} ): Promise { - const { appId, moduleContext, category, status, limit = 50, offset = 0 } = opts; + const { + appId, + moduleContext, + category, + status, + limit = 50, + offset = 0, + includeRealName = false, + } = opts; const conditions = [eq(userFeedback.isPublic, true), isNull(userFeedback.parentId)]; if (appId) conditions.push(eq(userFeedback.appId, appId)); @@ -224,34 +252,84 @@ export class FeedbackService { if (status) conditions.push(eq(userFeedback.status, status as any)); const rows = await this.db - .select() + .select({ feedback: userFeedback, author: this.authorSelection() }) .from(userFeedback) + .leftJoin(authUsers, eq(authUsers.id, userFeedback.userId)) .where(and(...conditions)) .orderBy(desc(userFeedback.score), desc(userFeedback.createdAt)) .limit(limit) .offset(offset); - return rows.map(redact); + return rows.map((r) => redact(r.feedback, r.author, { includeRealName })); } /** Replies for a single parent item (1-level threading). */ - async getReplies(parentId: string): Promise { + async getReplies( + parentId: string, + opts: { includeRealName?: boolean } = {} + ): Promise { const rows = await this.db - .select() + .select({ feedback: userFeedback, author: this.authorSelection() }) .from(userFeedback) + .leftJoin(authUsers, eq(authUsers.id, userFeedback.userId)) .where(and(eq(userFeedback.parentId, parentId), eq(userFeedback.isPublic, true))) .orderBy(userFeedback.createdAt); - return rows.map(redact); + return rows.map((r) => + redact(r.feedback, r.author, { includeRealName: opts.includeRealName ?? false }) + ); + } + + /** + * Public Eulen-Profil — alle public-Posts unter dem display_hash plus + * aggregierte Karma vom Author. Liefert auch ein leeres Array (kein + * Throw), wenn der Hash nirgends auftaucht; das macht das Frontend + * easier (keine 404-Race auf dynamic IDs). + */ + async getEulenProfile(displayHash: string): Promise<{ + displayHash: string; + displayName: string | null; + karma: number; + items: PublicFeedbackItem[]; + }> { + const rows = await this.db + .select({ feedback: userFeedback, author: this.authorSelection() }) + .from(userFeedback) + .leftJoin(authUsers, eq(authUsers.id, userFeedback.userId)) + .where(and(eq(userFeedback.displayHash, displayHash), eq(userFeedback.isPublic, true))) + .orderBy(desc(userFeedback.createdAt)) + .limit(200); + + const items = rows.map((r) => redact(r.feedback, r.author, { includeRealName: false })); + const displayName = items[0]?.displayName ?? null; + const karma = rows[0]?.author?.communityKarma ?? 0; + + return { displayHash, displayName, karma, items }; } /** Single public item by id. Returns null if not found / not public. */ - async getPublicItem(id: string): Promise { + async getPublicItem( + id: string, + opts: { includeRealName?: boolean } = {} + ): Promise { const [row] = await this.db - .select() + .select({ feedback: userFeedback, author: this.authorSelection() }) .from(userFeedback) + .leftJoin(authUsers, eq(authUsers.id, userFeedback.userId)) .where(and(eq(userFeedback.id, id), eq(userFeedback.isPublic, true))) .limit(1); - return row ? redact(row) : null; + return row + ? redact(row.feedback, row.author, { includeRealName: opts.includeRealName ?? false }) + : null; + } + + /** Selection helper — picks the auth-user columns we need. Drizzle + * treats the missing-author case (deleted user, FK orphan) as null. */ + private authorSelection() { + return { + name: authUsers.name, + communityShowRealName: authUsers.communityShowRealName, + communityKarma: authUsers.communityKarma, + }; } // ── Authenticated reads ─────────────────────────────────────────── @@ -273,9 +351,10 @@ export class FeedbackService { */ async getMyReactedItems(userId: string, limit = 100): Promise { const rows = await this.db - .selectDistinct({ feedback: userFeedback }) + .selectDistinct({ feedback: userFeedback, author: this.authorSelection() }) .from(feedbackReactions) .innerJoin(userFeedback, eq(feedbackReactions.feedbackId, userFeedback.id)) + .leftJoin(authUsers, eq(authUsers.id, userFeedback.userId)) .where( and( eq(feedbackReactions.userId, userId), @@ -286,7 +365,7 @@ export class FeedbackService { .orderBy(desc(userFeedback.updatedAt)) .limit(limit); - return rows.map((r) => redact(r.feedback)); + return rows.map((r) => redact(r.feedback, r.author, { includeRealName: true })); } /** Map of emoji → boolean for the requesting user on a feedback item. */ @@ -315,14 +394,20 @@ export class FeedbackService { throw new BadRequestError(`Unsupported emoji: ${emoji}`); } - // Ensure target item exists. + // Ensure target item exists + grab the author for karma tracking. const [item] = await this.db - .select({ id: userFeedback.id }) + .select({ id: userFeedback.id, authorId: userFeedback.userId }) .from(userFeedback) .where(eq(userFeedback.id, feedbackId)) .limit(1); if (!item) throw new NotFoundError('Feedback not found'); + // Karma is per-react (not per-author-per-user). Self-reactions + // don't count — that would be self-promotion. Founder users also + // don't farm karma off each other since they tend to react on + // their own things; the author check below handles both cases. + const counts = item.authorId !== userId; + // Try to insert (react). If conflicting → user already reacted, so unreact. const inserted = await this.db .insert(feedbackReactions) @@ -347,6 +432,21 @@ export class FeedbackService { userHasReacted = true; } + // Author karma: +1 on react, -1 on unreact (per emoji-toggle). + // Skipped when the reactor is the author themselves so people + // can't farm karma off their own posts. Floor-clamped at 0 so + // edge-cases (e.g. author deletes the row externally) don't go + // negative. + if (counts && item.authorId) { + const delta = userHasReacted ? 1 : -1; + await this.db + .update(authUsers) + .set({ + communityKarma: sql`GREATEST(${authUsers.communityKarma} + ${delta}, 0)`, + }) + .where(eq(authUsers.id, item.authorId)); + } + // Recompute aggregated reactions + score for this item. const aggregated = await this.recomputeReactions(feedbackId); return { ...aggregated, userHasReacted }; @@ -643,8 +743,13 @@ export class FeedbackService { } /** Strips userId / displayHash / deviceInfo from a row. */ -function redact(row: typeof userFeedback.$inferSelect): PublicFeedbackItem { - return { +function redact( + row: FeedbackRow, + author: AuthUserRow = null, + options: { includeRealName?: boolean } = {} +): PublicFeedbackItem { + const includeReal = options.includeRealName ?? false; + const item: PublicFeedbackItem = { id: row.id, appId: row.appId, title: row.title, @@ -653,13 +758,21 @@ function redact(row: typeof userFeedback.$inferSelect): PublicFeedbackItem { status: row.status, moduleContext: row.moduleContext, parentId: row.parentId, + // displayHash is the public Pseudonym-ID. One-way (SHA256 of + // userId+secret), safe to expose; userId itself never leaves. + displayHash: row.displayHash ?? '', displayName: row.displayName ?? 'Anonym', reactions: (row.reactions as Record) ?? {}, score: row.score, adminResponse: row.adminResponse, createdAt: row.createdAt, updatedAt: row.updatedAt, + karma: author?.communityKarma ?? 0, }; + if (includeReal && author?.communityShowRealName && author.name) { + item.realName = author.name; + } + return item; } export { ALLOWED_EMOJIS, REACTION_WEIGHTS }; diff --git a/services/mana-auth/sql/008_community_identity.sql b/services/mana-auth/sql/008_community_identity.sql new file mode 100644 index 000000000..e471ef9d8 --- /dev/null +++ b/services/mana-auth/sql/008_community_identity.sql @@ -0,0 +1,20 @@ +-- 008_community_identity.sql +-- +-- Phase 3.C von docs/plans/feedback-rewards-and-identity.md. +-- +-- Community-Hub Opt-Ins für jeden User: +-- - community_show_real_name: legt offen, ob der echte name neben +-- der eulen-pseudonym im community-feed angezeigt wird (default off). +-- - community_karma: counter — eine pro Reaction die jemand auf einen +-- eigenen Post macht. Treibt die Bronze/Silver/Gold/Platin-Tier-Badge. +-- +-- Apply manually: +-- psql "$DATABASE_URL" -f services/mana-auth/sql/008_community_identity.sql + +BEGIN; + +ALTER TABLE auth.users + ADD COLUMN IF NOT EXISTS community_show_real_name boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS community_karma integer NOT NULL DEFAULT 0; + +COMMIT; diff --git a/services/mana-auth/src/db/schema/auth.ts b/services/mana-auth/src/db/schema/auth.ts index 2a2e9295f..ae97530c6 100644 --- a/services/mana-auth/src/db/schema/auth.ts +++ b/services/mana-auth/src/db/schema/auth.ts @@ -53,6 +53,14 @@ export const users = authSchema.table('users', { // → Look → Templates). The flow is skippable, but even a skip sets // this timestamp so we don't re-prompt. See docs/plans/onboarding-flow.md. onboardingCompletedAt: timestamp('onboarding_completed_at', { withTimezone: true }), + // Community-Hub identity opt-ins (Phase 3.C of feedback-rewards-and-identity). + // Off by default — users stay anonymous as their tier-pseudonym ("Wachsame + // Eule #4528"). Opt-in shows the real `name` next to the pseudonym in the + // auth-required community feed only; the public-mirror NEVER exposes it. + communityShowRealName: boolean('community_show_real_name').default(false).notNull(), + // Karma += 1 per reaction received from another user, decremented on unreact. + // Drives the public Bronze/Silver/Gold/Platinum-Eulen tier badge. + communityKarma: integer('community_karma').default(0).notNull(), }); // Sessions table (Better Auth schema) diff --git a/services/mana-auth/src/routes/me.ts b/services/mana-auth/src/routes/me.ts index 207a59e7b..39c179b95 100644 --- a/services/mana-auth/src/routes/me.ts +++ b/services/mana-auth/src/routes/me.ts @@ -72,9 +72,15 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) { const body = (await c.req.json().catch(() => ({}))) as { name?: unknown; image?: unknown; + communityShowRealName?: unknown; }; - const patch: { name?: string; image?: string; updatedAt: Date } = { + const patch: { + name?: string; + image?: string; + communityShowRealName?: boolean; + updatedAt: Date; + } = { updatedAt: new Date(), }; if (typeof body.name === 'string') { @@ -87,8 +93,11 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) { if (typeof body.image === 'string') { patch.image = body.image; } + if (typeof body.communityShowRealName === 'boolean') { + patch.communityShowRealName = body.communityShowRealName; + } - if (!('name' in patch) && !('image' in patch)) { + if (!('name' in patch) && !('image' in patch) && !('communityShowRealName' in patch)) { return c.json({ error: 'no fields to update' }, 400); } @@ -96,10 +105,19 @@ export function createMeRoutes(userDataService: UserDataService, db: Database) { .update(users) .set(patch) .where(eq(users.id, user.userId)) - .returning({ id: users.id, name: users.name, image: users.image }); + .returning({ + id: users.id, + name: users.name, + image: users.image, + communityShowRealName: users.communityShowRealName, + }); if (!updated) return c.json({ error: 'User not found' }, 404); - return c.json({ name: updated.name, image: updated.image }); + return c.json({ + name: updated.name, + image: updated.image, + communityShowRealName: updated.communityShowRealName, + }); }) ); }