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
+
+
+
+ {#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 (
+ ``
+ );
+}
+
+/** 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,
+ });
})
);
}