managarten/services/mana-auth/src/routes/me.ts
Till JS ee5bb2871c feat(community): Phase 3.C — Identität (Avatar + Klarname-Toggle + Karma + Eulen-Profil)
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.
- <EulenAvatar> 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) <noreply@anthropic.com>
2026-04-27 15:15:16 +02:00

123 lines
4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Me routes — GDPR self-service data management
*
* GET /data — Full user data summary (auth, credits, projects)
* GET /data/export — Download all data as JSON
* DELETE /data — Delete all user data (right to be forgotten)
*/
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import type { AuthUser } from '../middleware/jwt-auth';
import type { UserDataService } from '../services/user-data';
import type { Database } from '../db/connection';
import { users } from '../db/schema/auth';
import { sendAccountDeletionEmail } from '../email/send';
export function createMeRoutes(userDataService: UserDataService, db: Database) {
return (
new Hono<{ Variables: { user: AuthUser } }>()
// ─── Get full user data summary ─────────────────────────
.get('/data', async (c) => {
const user = c.get('user');
const summary = await userDataService.getUserDataSummary(user.userId);
if (!summary) {
return c.json({ error: 'User not found' }, 404);
}
return c.json(summary);
})
// ─── Export user data as JSON download ──────────────────
.get('/data/export', async (c) => {
const user = c.get('user');
const exportData = await userDataService.exportUserData(user.userId);
if (!exportData) {
return c.json({ error: 'User not found' }, 404);
}
const filename = `meine-daten-${new Date().toISOString().split('T')[0]}.json`;
const json = JSON.stringify(exportData, null, 2);
return new Response(json, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
})
// ─── Delete all user data ───────────────────────────────
.delete('/data', async (c) => {
const user = c.get('user');
const result = await userDataService.deleteUserData(user.userId, user.email);
// Send confirmation email (fire-and-forget)
sendAccountDeletionEmail(user.email).catch(() => {});
return c.json(result);
})
// ─── Update profile (name, avatar) ──────────────────────
// Minimal patch endpoint used by the onboarding flow and
// Settings → Profile. JWT-based like the rest of /me/*; the
// updated name only lands in the user's JWT on next mint, so
// the caller is responsible for refreshing its in-memory
// representation of authStore.user. See docs/plans/onboarding-flow.md.
.patch('/profile', async (c) => {
const user = c.get('user');
const body = (await c.req.json().catch(() => ({}))) as {
name?: unknown;
image?: unknown;
communityShowRealName?: unknown;
};
const patch: {
name?: string;
image?: string;
communityShowRealName?: boolean;
updatedAt: Date;
} = {
updatedAt: new Date(),
};
if (typeof body.name === 'string') {
const trimmed = body.name.trim();
if (trimmed.length < 1 || trimmed.length > 80) {
return c.json({ error: 'name must be 180 characters' }, 400);
}
patch.name = trimmed;
}
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) && !('communityShowRealName' in patch)) {
return c.json({ error: 'no fields to update' }, 400);
}
const [updated] = await db
.update(users)
.set(patch)
.where(eq(users.id, user.userId))
.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,
communityShowRealName: updated.communityShowRealName,
});
})
);
}