Compare commits

..

6 commits

Author SHA1 Message Date
Sebastian Mayer
0de4b9c9f8 Fix: fehlenden Link-Import in hellfireclub/page.tsx ergaenzt
Some checks failed
CI / Lint, Typecheck, Test, Build (push) Has been cancelled
af66d49 fuegte einen <Link> auf /profil/[handleSlug] ins Leaderboard ein,
aber der Import (import Link from "next/link") fehlte -> Admin-Typecheck rot
("Cannot find name 'Link'"). Import nachgetragen.

Verifiziert: Typecheck 3/3 gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:22:59 +02:00
Sebastian Mayer
c6a7b5b1f1 Fix: Fremdprofil-Seite nachgereicht + clubLeaderboard liefert handleSlug
Vervollstaendigt den unvollstaendig/inkonsistent committeten UI-Commit af66d49:

- apps/admin/app/(authed)/profil/[handle]/page.tsx war NIE committet worden
  (blieb untracked). Jetzt drin: oeffentliche Profilseite mit Avatar/Handle/
  Bio/Punkten/Streak und NOT_FOUND-Handling fuer private/unbekannte Profile.
- clubLeaderboard liegt in tracking.ts (frueherer Fix-Versuch landete
  faelschlich in dashboard.ts und blieb wirkungslos). tracking.ts liefert
  jetzt pro Eintrag handleSlug, aber nur fuer profileVisible=true (sonst null
  = kein toter Link). Damit funktioniert die in af66d49 schon eingebaute
  Leaderboard-Verlinkung und der Admin-Typecheck ist wieder gruen.
- Datenschutz-Auflage: user.email wird im clubLeaderboard nicht mehr geladen
  (Response gab sie nie aus). name mit Fallback "Hellth User" reicht.
  (Art. 5 DSGVO, Datenminimierung.)

Verifiziert: Typecheck 3/3, 75 API-Tests + 10 Admin-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:11:38 +02:00
Sebastian Mayer
af66d49f2c Feature: Fremdprofil-Seite /profil/[handle] + klickbare Usernames im Leaderboard
UI-Teil zum Fremdprofil-Backend (2dfc770).

apps/admin/app/(authed)/profil/[handle]/page.tsx:
- Oeffentliche Profilseite: Avatar/Initiale, Handle, Bio, Punkte (gesamt +
  30 Tage) und aktive Tage (7-Tage-Streak).
- Sauberes NOT_FOUND-Handling fuer private/unbekannte Profile.
- Hinweis, dass Gesundheitsdaten immer privat bleiben.

dashboard.ts (clubLeaderboard):
- Liefert pro Eintrag einen handleSlug, aber NUR fuer profileVisible=true
  (sonst null -> kein toter Link).

hellfireclub/page.tsx:
- Leaderboard-Namen werden zu Links auf /profil/[handleSlug], wenn das
  Profil oeffentlich ist; sonst unveraenderter Text.

Verifiziert: Typecheck 3/3, 75 API-Tests + 10 Admin-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:58:45 +02:00
Sebastian Mayer
2dfc770950 Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer
Backend fuer oeffentliche Community-Fremdprofile (Stufe 2 der Roadmap).

handle.ts:
- handleToSlug: KochHeld#A23B -> KochHeld-A23B (URL-tauglich, '#' geht
  nicht im Pfad).
- parseHandleSlug: zerlegt am LETZTEN Bindestrich (Username darf Bindestriche
  enthalten), validiert Username + Discriminator gegen das Alphabet.

users.publicProfileByHandle (protectedProcedure):
- DATENSCHUTZ: liefert nur bei profileVisible=true, sonst NOT_FOUND (privates
  und nicht existierendes Profil sind von aussen ununterscheidbar).
- Strikte Feld-Whitelist: handle, username, discriminator, bio, image.
  NIEMALS Art.-9-Daten (Gewicht/Kalorien/Ziele), keine Kontakt-/Auth-Felder.
- Aggregat-Punkte gesamt + 30 Tage aus activity_logs.points, plus
  anonymisierte Aktivitaets-Streak (distinkte aktive Tage der letzten 7).

Tests (node:test): 9 Slug-Tests + 6 Endpoint-Tests (sichtbar, Leak-Schutz,
NOT_FOUND bei privat/unbekannt/ungueltigem Slug, Auth-Pflicht).
Verifiziert: 75 API-Tests gruen, Typecheck 3/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:50:50 +02:00
Sebastian Mayer
9ab6db9c0e Feature: Community-Profil-Card im Einstellungen-Profil-Tab
Erganzt den Profil-Tab um eine Community-Profil-Karte (UI-Teil zum
Backend-Fundament 634e67c):
- Benutzername-Feld mit Hinweis auf den vergebenen Handle (Username#XXXX).
- Bio-Textarea (max 280 Zeichen) mit Zeichenzaehler.
- Sichtbarkeits-Checkbox (Privacy-by-Default: aus) mit Datenschutzhinweis,
  dass Gewicht, Kalorien und Ziele immer privat bleiben.
- Eigener Submit, der setMyHandle (nur bei eingegebenem Username) und
  updateMyCommunityProfile aufruft und myProfile invalidiert.

Typecheck gruen (db/api/admin), Admin-Tests 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:59:59 +02:00
Sebastian Mayer
634e67c602 Feature: Community-Profil-Fundament (Handle, Bio, Sichtbarkeit) - Backend
Battle.net-Style Handle Username#XXXX als Basis fuer kommende
Community-Features (Stufe 2 der Roadmap). Diese Stufe: Schema, Migration,
Generator und API. Die UI im Profil-Tab folgt in einem eigenen Commit.

Schema (packages/db/src/schema/auth.ts):
- Neue Spalten an der user-Tabelle: username, discriminator, bio (alle
  nullable), profileVisible (notNull, default false = Privacy-by-Default).
- uniqueIndex auf (username, discriminator); NULLs distinct, greift also
  nur bei vollstaendigen Handles. Index user_username_idx auf username
  fuer die Handle-Erstvergabe-Query.
- Migration 0019_community_profile, per drizzle-kit generate erzeugt,
  driftfrei (zweiter generate: keine Aenderung), drizzle-kit check ok.
  Noch nicht gegen die DB angewendet.

Generator (packages/api/src/lib/handle.ts):
- Discriminator aus verwechslungsarmem Alphabet, Kollisions-Retry.
- formatHandle, validateUsername. 17 node:test-Unit-Tests.

API (packages/api/src/trpc/routers/users.ts):
- myProfile um die Felder + abgeleitetes handle erweitert (Feld-Whitelist).
- setMyHandle: Discriminator stabil bei Erst-Vergabe, Kollisionspruefung
  inkl. DB-unique-violation-Fallback (PG 23505).
- updateMyCommunityProfile: bio + Sichtbarkeit, strikt userId-gescoped.

Verifiziert: Typecheck gruen (db/api/admin), 62 API-Tests gruen,
drizzle-kit check ok. Datenmodell-Architekt abgenommen (Index-Auflage
erfuellt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:57:24 +02:00
13 changed files with 5655 additions and 26 deletions

View file

@ -139,6 +139,10 @@ export default function EinstellungenPage() {
const [postalCode, setPostalCode] = useState("");
const [city, setCity] = useState("");
const [image, setImage] = useState<string | null>(null);
// Community-Profil (Handle, Bio, Sichtbarkeit)
const [username, setUsername] = useState("");
const [bio, setBio] = useState("");
const [profileVisible, setProfileVisible] = useState(false);
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>([
"vegan",
"vegetarian",
@ -173,6 +177,9 @@ export default function EinstellungenPage() {
setPostalCode(profileQuery.data.postalCode ?? "");
setCity(profileQuery.data.city ?? "");
setImage(profileQuery.data.image ?? null);
setUsername(profileQuery.data.username ?? "");
setBio(profileQuery.data.bio ?? "");
setProfileVisible(profileQuery.data.profileVisible ?? false);
}
}, [profileQuery.data]);
@ -191,6 +198,34 @@ export default function EinstellungenPage() {
},
});
const setMyHandle = trpc.users.setMyHandle.useMutation();
const updateMyCommunityProfile = trpc.users.updateMyCommunityProfile.useMutation();
const communityPending = setMyHandle.isPending || updateMyCommunityProfile.isPending;
const onSubmitCommunity = async (event: React.FormEvent) => {
event.preventDefault();
setStatusMessage(null);
setErrorMessage(null);
try {
// Handle nur setzen, wenn ein Username eingegeben wurde. Den Discriminator
// vergibt der Server stabil bei der Erst-Vergabe.
const trimmedUsername = username.trim();
if (trimmedUsername) {
await setMyHandle.mutateAsync({ username: trimmedUsername });
}
await updateMyCommunityProfile.mutateAsync({
bio: bio.trim() || null,
profileVisible,
});
await utils.users.myProfile.invalidate();
setStatusMessage("Community-Profil gespeichert.");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "Speichern fehlgeschlagen.",
);
}
};
const changeMyPassword = trpc.users.changeMyPassword.useMutation({
onSuccess: () => {
setCurrentPassword("");
@ -562,6 +597,75 @@ export default function EinstellungenPage() {
</SectionCard>
) : null}
{active === "profil" ? (
<form onSubmit={onSubmitCommunity}>
<SectionCard
title="Community-Profil"
description="Dein öffentlicher Auftritt. Gewicht, Kalorien und Ziele bleiben immer privat."
>
<div className="space-y-4">
<div>
<label className={labelClass} htmlFor="community-username">
Benutzername
</label>
<input
id="community-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="z. B. KochHeld"
className={inputClass}
/>
<p className="mt-1.5 text-xs text-gray-500">
{profileQuery.data?.handle
? `Dein Handle: ${profileQuery.data.handle}`
: "Buchstaben, Ziffern, _ und -. Eine vierstellige Kennung (#XXXX) wird automatisch und dauerhaft vergeben."}
</p>
</div>
<div>
<label className={labelClass} htmlFor="community-bio">
Über mich
</label>
<textarea
id="community-bio"
value={bio}
maxLength={280}
rows={3}
onChange={(event) => setBio(event.target.value)}
placeholder="Kurz über dich – optional."
className={inputClass}
/>
<p className="mt-1.5 text-right text-xs text-gray-500">
{bio.length}/280
</p>
</div>
<label className="flex items-start gap-3 rounded-lg bg-gray-50 p-3 text-sm">
<input
type="checkbox"
checked={profileVisible}
onChange={(event) => setProfileVisible(event.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0"
/>
<span className="text-gray-700">
<span className="font-semibold">Profil öffentlich sichtbar machen</span>
<span className="mt-0.5 block text-gray-500">
Andere können dann deinen Benutzernamen und deine Bio sehen.
Gewicht, Kalorien und Ziele bleiben immer privat.
</span>
</span>
</label>
<div className="flex justify-end">
<button type="submit" disabled={communityPending} className={buttonPrimary}>
{communityPending ? "Speichert..." : "Community-Profil speichern"}
</button>
</div>
</div>
</SectionCard>
</form>
) : null}
{active === "ernaehrung" ? (
<SectionCard
title="Rezept- und Mahlzeiten-Präferenzen"

View file

@ -11,6 +11,7 @@ import {
TrophyIcon,
UserGroupIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { useMemo, useState } from "react";
import {
ClubActivityCategory,
@ -436,7 +437,16 @@ export default function HellfireclubPage() {
<div key={entry.userKey} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
<div className="grid h-9 w-9 place-items-center rounded-full bg-gray-950 text-sm font-black text-white">{index + 1}</div>
<div className="min-w-0 flex-1">
{entry.handleSlug ? (
<Link
href={`/profil/${entry.handleSlug}`}
className="truncate text-sm font-black text-[#0F766E] underline-offset-2 hover:underline"
>
{entry.name}
</Link>
) : (
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
)}
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
</div>
<div className="text-right">

View file

@ -0,0 +1,113 @@
"use client";
import { FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useParams } from "next/navigation";
import { DataPageLayout } from "@/app/_components/data-page-layout";
import { trpc } from "@/lib/trpc";
export default function PublicProfilePage() {
const params = useParams<{ handle: string }>();
const handleSlug = decodeURIComponent(params.handle ?? "");
const profileQuery = trpc.users.publicProfileByHandle.useQuery(
{ handle: handleSlug },
{ retry: false },
);
if (profileQuery.isLoading) {
return (
<DataPageLayout title="Profil">
<p className="text-sm text-gray-500">Profil wird geladen …</p>
</DataPageLayout>
);
}
if (profileQuery.isError || !profileQuery.data) {
return (
<DataPageLayout title="Profil nicht gefunden">
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-600">
Dieses Profil gibt es nicht oder es ist privat. Nur Mitglieder, die ihr
Profil öffentlich geschaltet haben, sind hier sichtbar.
</div>
</DataPageLayout>
);
}
const profile = profileQuery.data;
const initial = (profile.username ?? "?").charAt(0).toUpperCase();
return (
<DataPageLayout title={profile.handle ?? profile.username ?? "Profil"}>
<div className="space-y-4">
<div className="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-5">
{profile.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.image}
alt={profile.username ?? "Avatar"}
className="h-16 w-16 rounded-full object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#0F766E] text-xl font-bold text-white">
{initial}
</div>
)}
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-gray-900">
{profile.username}
<span className="font-normal text-gray-400">#{profile.discriminator}</span>
</p>
{profile.bio ? (
<p className="mt-1 whitespace-pre-line text-sm text-gray-600">{profile.bio}</p>
) : (
<p className="mt-1 text-sm text-gray-400">Keine Bio hinterlegt.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
icon={<FireIcon className="h-5 w-5 text-[#0F766E]" />}
label="Punkte gesamt"
value={profile.points.total.toLocaleString("de-DE")}
/>
<StatCard
icon={<FireIcon className="h-5 w-5 text-[#0F766E]" />}
label="Punkte (30 Tage)"
value={profile.points.last30Days.toLocaleString("de-DE")}
/>
<StatCard
icon={<SparklesIcon className="h-5 w-5 text-[#0F766E]" />}
label="Aktive Tage (7 Tage)"
value={`${profile.activeDaysLast7}/7`}
/>
</div>
<p className="px-1 text-xs text-gray-400">
Gesundheitsdaten wie Gewicht, Kalorien und Ziele sind immer privat und
werden hier nie angezeigt.
</p>
</div>
</DataPageLayout>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 text-xs font-medium text-gray-500">
{icon}
{label}
</div>
<p className="mt-2 text-2xl font-bold text-gray-900">{value}</p>
</div>
);
}

View file

@ -0,0 +1,134 @@
// Community-Handle im Battle.net-Stil: "Username#XXXX".
//
// Der Username ist frei wählbar und darf mehrfach vorkommen. Eindeutig wird
// ein Nutzer erst durch die Kombination Username + Discriminator. Der
// Discriminator wird einmalig bei der Erst-Vergabe erzeugt und bleibt danach
// stabil, auch wenn der Username später geändert wird.
// Verwechslungsarmes Alphabet: kein 0/O, 1/I/L, damit Handles vorlesbar bleiben.
export const DISCRIMINATOR_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
export const DISCRIMINATOR_LENGTH = 4;
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 24;
export const BIO_MAX_LENGTH = 280;
// Erlaubt Buchstaben, Ziffern, Unterstrich und Bindestrich. Keine Leerzeichen,
// kein '#' (reserviert als Trenner), keine Umlaute (Handle bleibt URL-tauglich).
const USERNAME_PATTERN = /^[A-Za-z0-9_-]+$/;
export type RandomFn = () => number;
/**
* Erzeugt einen zufälligen Discriminator (z. B. "A12B").
*
* `random` ist injizierbar, damit der Generator deterministisch getestet werden
* kann; im Betrieb wird Math.random verwendet.
*/
export function generateDiscriminator(random: RandomFn = Math.random): string {
let out = "";
for (let i = 0; i < DISCRIMINATOR_LENGTH; i++) {
const idx = Math.floor(random() * DISCRIMINATOR_ALPHABET.length);
out += DISCRIMINATOR_ALPHABET[idx];
}
return out;
}
/**
* Erzeugt einen Discriminator, der noch nicht in `taken` vorkommt.
*
* `taken` enthält die bereits vergebenen Discriminatoren für genau diesen
* Username (Kollision ist nur innerhalb desselben Usernames relevant). Nach
* `maxAttempts` erfolglosen Versuchen wird ein Fehler geworfen, damit der
* Aufrufer das nicht still ignoriert.
*/
export function generateUniqueDiscriminator(
taken: Iterable<string>,
random: RandomFn = Math.random,
maxAttempts = 50,
): string {
const used = new Set<string>();
for (const t of taken) used.add(t.toUpperCase());
// Wenn der gesamte Raum voll ist, gar nicht erst probieren.
const space = DISCRIMINATOR_ALPHABET.length ** DISCRIMINATOR_LENGTH;
if (used.size >= space) {
throw new Error("Keine freien Discriminatoren mehr für diesen Namen");
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidate = generateDiscriminator(random);
if (!used.has(candidate)) return candidate;
}
throw new Error("Konnte keinen freien Discriminator erzeugen");
}
/** Liefert den anzeigefertigen Handle oder null, wenn (noch) keiner vergeben ist. */
export function formatHandle(
username: string | null | undefined,
discriminator: string | null | undefined,
): string | null {
if (!username || !discriminator) return null;
return `${username}#${discriminator}`;
}
export type UsernameValidation =
| { ok: true; value: string }
| { ok: false; error: string };
/**
* Prüft und normalisiert einen vom Nutzer gewählten Username.
* Trimmt Whitespace, lässt aber Groß-/Kleinschreibung wie eingegeben.
*/
export function validateUsername(raw: string): UsernameValidation {
const value = raw.trim();
if (value.length < USERNAME_MIN_LENGTH) {
return { ok: false, error: `Mindestens ${USERNAME_MIN_LENGTH} Zeichen` };
}
if (value.length > USERNAME_MAX_LENGTH) {
return { ok: false, error: `Höchstens ${USERNAME_MAX_LENGTH} Zeichen` };
}
if (!USERNAME_PATTERN.test(value)) {
return {
ok: false,
error: "Nur Buchstaben, Ziffern, Unterstrich und Bindestrich erlaubt",
};
}
return { ok: true, value };
}
/**
* Wandelt den anzeigefertigen Handle in eine URL-taugliche Form um:
* "KochHeld#A12B" -> "KochHeld-A12B". Das '#' ist als URL-Fragment-Zeichen
* nicht im Pfad nutzbar, daher der Bindestrich als Trenner.
*/
export function handleToSlug(
username: string | null | undefined,
discriminator: string | null | undefined,
): string | null {
if (!username || !discriminator) return null;
return `${username}-${discriminator}`;
}
/**
* Zerlegt einen URL-Slug ("KochHeld-A12B") zurück in Username + Discriminator.
* Es wird am LETZTEN Bindestrich getrennt, weil der Username selbst Bindestriche
* enthalten darf. Gibt null zurück, wenn der Slug keine gültige Form hat.
*/
export function parseHandleSlug(
slug: string,
): { username: string; discriminator: string } | null {
const trimmed = slug.trim();
const lastDash = trimmed.lastIndexOf("-");
if (lastDash <= 0 || lastDash === trimmed.length - 1) return null;
const username = trimmed.slice(0, lastDash);
const discriminator = trimmed.slice(lastDash + 1).toUpperCase();
if (validateUsername(username).ok !== true) return null;
if (discriminator.length !== DISCRIMINATOR_LENGTH) return null;
for (const ch of discriminator) {
if (!DISCRIMINATOR_ALPHABET.includes(ch)) return null;
}
return { username, discriminator };
}

View file

@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BIO_MAX_LENGTH,
DISCRIMINATOR_ALPHABET,
DISCRIMINATOR_LENGTH,
formatHandle,
generateDiscriminator,
generateUniqueDiscriminator,
handleToSlug,
parseHandleSlug,
validateUsername,
} from "../lib/handle";
/** Liefert eine deterministische Random-Funktion, die der Reihe nach `values` ausgibt. */
function seededRandom(values: number[]): () => number {
let i = 0;
return () => values[i++ % values.length];
}
test("generateDiscriminator hat die konfigurierte Länge", () => {
assert.equal(generateDiscriminator().length, DISCRIMINATOR_LENGTH);
});
test("generateDiscriminator nutzt nur Zeichen aus dem Alphabet", () => {
for (let i = 0; i < 200; i++) {
for (const ch of generateDiscriminator()) {
assert.ok(DISCRIMINATOR_ALPHABET.includes(ch), `unerwartetes Zeichen: ${ch}`);
}
}
});
test("Alphabet enthält keine verwechselbaren Zeichen (0 O 1 I L)", () => {
for (const forbidden of ["0", "O", "1", "I", "L"]) {
assert.ok(!DISCRIMINATOR_ALPHABET.includes(forbidden), `verboten: ${forbidden}`);
}
});
test("generateDiscriminator ist deterministisch bei fester Random-Quelle", () => {
// random()=0 -> immer erstes Zeichen des Alphabets
assert.equal(
generateDiscriminator(() => 0),
DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH),
);
});
test("generateUniqueDiscriminator umgeht bereits vergebene Discriminatoren", () => {
const first = DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH);
const second =
DISCRIMINATOR_ALPHABET[1] + DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH - 1);
// Erster Versuch kollidiert (alles idx 0), zweiter Versuch erstes Zeichen = idx 1
const random = seededRandom([0, 0, 0, 0, 1 / DISCRIMINATOR_ALPHABET.length, 0, 0, 0]);
assert.equal(generateUniqueDiscriminator([first], random), second);
});
test("generateUniqueDiscriminator ignoriert Groß-/Kleinschreibung", () => {
const taken = DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH).toLowerCase();
const random = seededRandom([0, 0, 0, 0, 1 / DISCRIMINATOR_ALPHABET.length, 0, 0, 0]);
const result = generateUniqueDiscriminator([taken], random);
assert.notEqual(result, DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH));
});
test("generateUniqueDiscriminator wirft, wenn nach maxAttempts kein freier gefunden wird", () => {
const onlyZero = () => 0; // erzeugt immer denselben Discriminator
const taken = [DISCRIMINATOR_ALPHABET[0].repeat(DISCRIMINATOR_LENGTH)];
assert.throws(() => generateUniqueDiscriminator(taken, onlyZero, 10));
});
test("formatHandle baut Username#Discriminator", () => {
assert.equal(formatHandle("Held", "A23B"), "Held#A23B");
});
test("formatHandle liefert null ohne Username", () => {
assert.equal(formatHandle(null, "A23B"), null);
});
test("formatHandle liefert null ohne Discriminator", () => {
assert.equal(formatHandle("Held", null), null);
});
test("validateUsername akzeptiert gültige Namen und trimmt", () => {
assert.deepEqual(validateUsername(" Held_2 "), { ok: true, value: "Held_2" });
});
test("validateUsername lehnt zu kurze Namen ab", () => {
assert.equal(validateUsername("ab").ok, false);
});
test("validateUsername lehnt zu lange Namen ab", () => {
assert.equal(validateUsername("a".repeat(25)).ok, false);
});
test("validateUsername lehnt '#' und Leerzeichen ab", () => {
assert.equal(validateUsername("Held#1").ok, false);
assert.equal(validateUsername("Held Held").ok, false);
});
test("validateUsername lehnt Umlaute ab (Handle bleibt URL-tauglich)", () => {
assert.equal(validateUsername("Hähnchen").ok, false);
});
test("Bio-Limit ist 280", () => {
assert.equal(BIO_MAX_LENGTH, 280);
});
test("handleToSlug baut Username-Discriminator (URL-tauglich, kein #)", () => {
assert.equal(handleToSlug("KochHeld", "A23B"), "KochHeld-A23B");
});
test("handleToSlug liefert null ohne vollstaendigen Handle", () => {
assert.equal(handleToSlug(null, "A23B"), null);
assert.equal(handleToSlug("KochHeld", null), null);
});
test("parseHandleSlug zerlegt am letzten Bindestrich", () => {
assert.deepEqual(parseHandleSlug("KochHeld-A23B"), {
username: "KochHeld",
discriminator: "A23B",
});
});
test("parseHandleSlug behaelt Bindestriche im Username", () => {
assert.deepEqual(parseHandleSlug("Koch-Held-A23B"), {
username: "Koch-Held",
discriminator: "A23B",
});
});
test("parseHandleSlug normalisiert den Discriminator auf Grossschreibung", () => {
assert.deepEqual(parseHandleSlug("KochHeld-a23b"), {
username: "KochHeld",
discriminator: "A23B",
});
});
test("parseHandleSlug lehnt ungueltige Slugs ab", () => {
assert.equal(parseHandleSlug("KochHeld"), null); // kein Discriminator
assert.equal(parseHandleSlug("KochHeld-"), null); // leerer Discriminator
assert.equal(parseHandleSlug("-A23B"), null); // leerer Username
assert.equal(parseHandleSlug("KochHeld-A1"), null); // Discriminator zu kurz
assert.equal(parseHandleSlug("KochHeld-A1IB"), null); // I nicht im Alphabet
});
test("handleToSlug und parseHandleSlug sind invers", () => {
const slug = handleToSlug("Koch-Held", "A23B");
assert.ok(slug);
assert.deepEqual(parseHandleSlug(slug), {
username: "Koch-Held",
discriminator: "A23B",
});
});

View file

@ -1,6 +1,7 @@
import { schema } from "@hellth/db";
import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { handleToSlug } from "../../../lib/handle";
import { platformProcedure, protectedProcedure, router } from "../../init";
import {
auditHealthMutation,

View file

@ -2,6 +2,7 @@ import { schema } from "@hellth/db";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { handleToSlug } from "../../../lib/handle";
import { protectedProcedure, router } from "../../init";
import {
addDays,
@ -428,17 +429,32 @@ export const trackingRouter = router({
const users = userIds.length
? await ctx.db.query.user.findMany({
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
columns: { id: true, name: true, email: true },
columns: {
id: true,
name: true,
username: true,
discriminator: true,
profileVisible: true,
},
})
: [];
const userById = new Map(users.map((user) => [user.id, user]));
const scores = new Map<string, { userKey: string; name: string; points: number; completed: number }>();
const scores = new Map<
string,
{ userKey: string; name: string; handleSlug: string | null; points: number; completed: number }
>();
for (const log of logs) {
if (log.points <= 0) continue;
const user = userById.get(log.userId);
// Handle-Link nur fuer oeffentlich sichtbare Profile, sonst waere er
// ein toter Link (Profilseite liefert dann NOT_FOUND).
const handleSlug = user?.profileVisible
? handleToSlug(user.username, user.discriminator)
: null;
const current = scores.get(log.userId) ?? {
userKey: log.userId,
name: user?.name || user?.email || "Hellth User",
name: user?.name || "Hellth User",
handleSlug,
points: 0,
completed: 0,
};

View file

@ -1,8 +1,17 @@
import { schema } from "@hellth/db";
import { TRPCError } from "@trpc/server";
import { asc, eq, sql } from "drizzle-orm";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { z } from "zod";
import { auth } from "../../lib/auth";
import {
BIO_MAX_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
formatHandle,
generateUniqueDiscriminator,
parseHandleSlug,
validateUsername,
} from "../../lib/handle";
import { auditSecurityEvent } from "../../lib/security-audit";
import { platformProcedure, protectedProcedure, router } from "../init";
@ -19,6 +28,17 @@ const imageFieldSchema = z
const platformRoleSchema = z.enum(["user", "admin"]);
// Postgres-Fehlercode 23505 = unique_violation. Wird genutzt, um eine
// Race-Condition bei der Handle-Vergabe sauber in einen CONFLICT zu übersetzen.
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === "23505"
);
}
export const usersRouter = router({
myProfile: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.query.user.findFirst({
@ -30,6 +50,10 @@ export const usersRouter = router({
image: true,
role: true,
theme: true,
username: true,
discriminator: true,
bio: true,
profileVisible: true,
twoFactorEnabled: true,
twoFactorRecoveryRequired: true,
createdAt: true,
@ -50,6 +74,7 @@ export const usersRouter = router({
street: null,
postalCode: null,
city: null,
handle: formatHandle(user.username, user.discriminator),
};
}),
@ -82,6 +107,199 @@ export const usersRouter = router({
return { ok: true };
}),
// Vergibt oder ändert den Community-Username. Der Discriminator wird einmalig
// bei der Erst-Vergabe erzeugt und bleibt danach stabil, auch wenn der
// Username später geändert wird (stabile Identität).
setMyHandle: protectedProcedure
.input(
z.object({
username: z.string().trim().min(USERNAME_MIN_LENGTH).max(USERNAME_MAX_LENGTH),
}),
)
.mutation(async ({ ctx, input }) => {
const validation = validateUsername(input.username);
if (!validation.ok) {
throw new TRPCError({ code: "BAD_REQUEST", message: validation.error });
}
const username = validation.value;
const me = await ctx.db.query.user.findFirst({
where: (row, { eq: rowEq }) => rowEq(row.id, ctx.user.id),
columns: { discriminator: true },
});
if (!me) {
throw new TRPCError({ code: "NOT_FOUND", message: "Benutzer nicht gefunden" });
}
// Bestehenden Discriminator beibehalten (stabil). Nur bei Erst-Vergabe neu
// erzeugen, dann gegen die für diesen Username bereits vergebenen prüfen.
let discriminator: string;
if (!me.discriminator) {
const others = await ctx.db.query.user.findMany({
where: (row, { eq: rowEq }) => rowEq(row.username, username),
columns: { discriminator: true },
});
const taken = others
.map((row) => row.discriminator)
.filter((d): d is string => Boolean(d));
try {
discriminator = generateUniqueDiscriminator(taken);
} catch {
throw new TRPCError({
code: "CONFLICT",
message: "Dieser Name ist leider voll belegt. Bitte einen anderen wählen.",
});
}
} else {
discriminator = me.discriminator;
// Beim Umbenennen sicherstellen, dass die Kombination noch frei ist.
const clash = await ctx.db.query.user.findFirst({
where: (row, { eq: rowEq, and, ne }) =>
and(
rowEq(row.username, username),
rowEq(row.discriminator, discriminator),
ne(row.id, ctx.user.id),
),
columns: { id: true },
});
if (clash) {
throw new TRPCError({
code: "CONFLICT",
message: `${username}#${discriminator} ist bereits vergeben. Bitte einen anderen Namen wählen.`,
});
}
}
try {
await ctx.db
.update(schema.user)
.set({ username, discriminator, updatedAt: new Date() })
.where(eq(schema.user.id, ctx.user.id));
} catch (error) {
// Letzte Absicherung gegen die Race-Condition zwischen Prüfung und
// Schreiben: der UNIQUE-Index auf (username, discriminator) wirft bei
// einer gleichzeitig vergebenen Kombination einen DB-Fehler.
if (isUniqueViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `${username}#${discriminator} ist bereits vergeben. Bitte einen anderen Namen wählen.`,
});
}
throw error;
}
return { ok: true, handle: formatHandle(username, discriminator) };
}),
// Bio und Sichtbarkeit des eigenen Community-Profils. Privacy-by-Default:
// profileVisible ist erst nach aktiver Freigabe true.
updateMyCommunityProfile: protectedProcedure
.input(
z.object({
bio: z.string().trim().max(BIO_MAX_LENGTH).nullable().optional(),
profileVisible: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const patch: { bio?: string | null; profileVisible?: boolean; updatedAt: Date } = {
updatedAt: new Date(),
};
if (input.bio !== undefined) patch.bio = input.bio?.trim() || null;
if (input.profileVisible !== undefined) patch.profileVisible = input.profileVisible;
await ctx.db
.update(schema.user)
.set(patch)
.where(eq(schema.user.id, ctx.user.id));
return { ok: true };
}),
// Öffentliches Fremdprofil per Handle-Slug (z. B. "KochHeld-A12B").
// DATENSCHUTZ: liefert NUR, wenn der Eigentümer profileVisible=true gesetzt
// hat (sonst NOT_FOUND, damit die Existenz nicht durchsickert). Strikte
// Feld-Whitelist — ausschließlich öffentliche Identitäts- und Aggregatdaten,
// NIEMALS Art.-9-Gesundheitsdaten (Gewicht/Kalorien/Ziele) oder Kontaktdaten.
publicProfileByHandle: protectedProcedure
.input(z.object({ handle: z.string().min(1).max(64) }))
.query(async ({ ctx, input }) => {
const parsed = parseHandleSlug(input.handle);
if (!parsed) {
throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" });
}
const target = await ctx.db.query.user.findFirst({
where: (row, { and: andWhere, eq: rowEq }) =>
andWhere(
rowEq(row.username, parsed.username),
rowEq(row.discriminator, parsed.discriminator),
),
columns: {
id: true,
username: true,
discriminator: true,
bio: true,
image: true,
profileVisible: true,
},
});
// Privacy-Gate serverseitig: privates oder nicht existierendes Profil
// ist von außen nicht unterscheidbar (immer NOT_FOUND).
if (!target || !target.profileVisible) {
throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" });
}
// Aggregierte Aktivitäts-Punkte (kein Health-Detail). 30-Tage-Fenster
// über das date-Feld (YYYY-MM-DD) der activity_logs.
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
const since = thirtyDaysAgo.toISOString().slice(0, 10);
const [totalRow] = await ctx.db
.select({ points: sql<number>`coalesce(sum(${schema.activityLogs.points}), 0)` })
.from(schema.activityLogs)
.where(eq(schema.activityLogs.userId, target.id));
const [recentRow] = await ctx.db
.select({ points: sql<number>`coalesce(sum(${schema.activityLogs.points}), 0)` })
.from(schema.activityLogs)
.where(
and(
eq(schema.activityLogs.userId, target.id),
gte(schema.activityLogs.date, since),
),
);
// Anonymisierte Aktivitäts-Streak: Anzahl distinkter Tage mit Aktivität
// in den letzten 7 Tagen. Nur "wie aktiv", keine Inhalte/Werte.
const sevenDaysAgo = new Date();
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7);
const since7 = sevenDaysAgo.toISOString().slice(0, 10);
const [streakRow] = await ctx.db
.select({ days: sql<number>`count(distinct ${schema.activityLogs.date})` })
.from(schema.activityLogs)
.where(
and(
eq(schema.activityLogs.userId, target.id),
gte(schema.activityLogs.date, since7),
),
);
return {
handle: formatHandle(target.username, target.discriminator),
username: target.username,
discriminator: target.discriminator,
bio: target.bio,
image: target.image,
points: {
total: Number(totalRow?.points ?? 0),
last30Days: Number(recentRow?.points ?? 0),
},
activeDaysLast7: Number(streakRow?.days ?? 0),
};
}),
setTheme: protectedProcedure
.input(z.object({ theme: z.enum(["dark", "light"]).nullable() }))
.mutation(async ({ ctx, input }) => {

View file

@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import test from "node:test";
import { appRouter } from "./index";
// Schlanker Mock nur für users.publicProfileByHandle. Deckt sowohl
// query.user.findFirst (Profil-Lookup) als auch den select().from().where()
// Query-Builder (Punkte-Aggregate) ab.
type UserRow = {
id: string;
username: string | null;
discriminator: string | null;
bio: string | null;
image: string | null;
profileVisible: boolean;
} | null;
function createContext(targetUser: UserRow, aggregates: { total: number; recent: number; days: number }) {
const user = { id: "viewer", email: "viewer@example.org", role: "user" };
// Reihenfolge der select()-Aufrufe im Endpoint: total, recent(30T), streak(7T).
const selectResults = [
[{ points: aggregates.total }],
[{ points: aggregates.recent }],
[{ days: aggregates.days }],
];
let selectIndex = 0;
const db = {
query: {
user: {
findFirst: async () => targetUser,
},
},
select() {
const result = selectResults[selectIndex++] ?? [];
return {
from() {
return {
where() {
return Promise.resolve(result);
},
};
},
};
},
};
return {
db,
headers: new Headers(),
session: { user },
user,
userRole: "user",
isPlatformAdmin: false,
};
}
const visibleUser: UserRow = {
id: "target-1",
username: "KochHeld",
discriminator: "A23B",
bio: "Mag Meal Prep.",
image: null,
profileVisible: true,
};
test("publicProfileByHandle liefert sichtbares Profil mit Whitelist-Feldern + Aggregaten", async () => {
const caller = appRouter.createCaller(
createContext(visibleUser, { total: 340, recent: 80, days: 5 }) as never,
);
const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" });
assert.equal(result.handle, "KochHeld#A23B");
assert.equal(result.username, "KochHeld");
assert.equal(result.bio, "Mag Meal Prep.");
assert.equal(result.points.total, 340);
assert.equal(result.points.last30Days, 80);
assert.equal(result.activeDaysLast7, 5);
});
test("publicProfileByHandle leakt KEINE Health-/Kontaktfelder", async () => {
const caller = appRouter.createCaller(
createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never,
);
const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" });
for (const forbidden of ["email", "id", "weightKg", "calories", "profileVisible", "role", "createdAt"]) {
assert.equal(forbidden in result, false, `Feld ${forbidden} darf nicht geliefert werden`);
}
});
test("publicProfileByHandle wirft NOT_FOUND bei privatem Profil", async () => {
const caller = appRouter.createCaller(
createContext({ ...visibleUser, profileVisible: false }, { total: 0, recent: 0, days: 0 }) as never,
);
await assert.rejects(
() => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }),
(error: unknown) => {
assert.equal((error as { code?: string }).code, "NOT_FOUND");
return true;
},
);
});
test("publicProfileByHandle wirft NOT_FOUND bei unbekanntem Handle", async () => {
const caller = appRouter.createCaller(
createContext(null, { total: 0, recent: 0, days: 0 }) as never,
);
await assert.rejects(
() => caller.users.publicProfileByHandle({ handle: "Niemand-Z9Z9" }),
(error: unknown) => {
assert.equal((error as { code?: string }).code, "NOT_FOUND");
return true;
},
);
});
test("publicProfileByHandle wirft NOT_FOUND bei ungueltigem Slug", async () => {
const caller = appRouter.createCaller(
createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never,
);
await assert.rejects(
() => caller.users.publicProfileByHandle({ handle: "KeinDiscriminator" }),
(error: unknown) => {
assert.equal((error as { code?: string }).code, "NOT_FOUND");
return true;
},
);
});
test("publicProfileByHandle erfordert Authentifizierung", async () => {
const caller = appRouter.createCaller({
db: {},
headers: new Headers(),
session: null,
user: null,
userRole: "user",
isPlatformAdmin: false,
} as never);
await assert.rejects(
() => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }),
(error: unknown) => {
assert.equal((error as { code?: string }).code, "UNAUTHORIZED");
return true;
},
);
});

View file

@ -0,0 +1,6 @@
ALTER TABLE "user" ADD COLUMN "username" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "discriminator" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "bio" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "profileVisible" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "user_username_discriminator_unique" ON "user" USING btree ("username","discriminator");--> statement-breakpoint
CREATE INDEX "user_username_idx" ON "user" USING btree ("username");

File diff suppressed because it is too large Load diff

View file

@ -134,6 +134,13 @@
"when": 1780046007288,
"tag": "0018_outgoing_betty_ross",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1780134873711,
"tag": "0019_community_profile",
"breakpoints": true
}
]
}

View file

@ -1,7 +1,17 @@
// Better Auth erwartet genau diese Tabellen und Spaltennamen
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
@ -18,9 +28,28 @@ export const user = pgTable("user", {
emailVerified: boolean("emailVerified").notNull().default(false),
image: text("image"),
theme: text("theme"),
// Community-Profil (Battle.net-Style Handle "Username#XXXX").
// username + discriminator sind nullable, weil der Handle erst bei
// Gelegenheit vergeben wird. Der discriminator wird einmalig bei der
// Erst-Vergabe gesetzt und bleibt danach stabil.
username: text("username"),
discriminator: text("discriminator"),
bio: text("bio"),
// Privacy-by-Default: Profil ist privat, bis der Nutzer es freigibt.
profileVisible: boolean("profileVisible").notNull().default(false),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
},
(table) => ({
handleUnique: uniqueIndex("user_username_discriminator_unique").on(
table.username,
table.discriminator,
),
// Index für die Handle-Erstvergabe: dort werden alle Konten mit demselben
// username geladen, um belegte Discriminatoren zu meiden (users.setMyHandle).
usernameIdx: index("user_username_idx").on(table.username),
}),
);
export const session = pgTable("session", {
id: text("id").primaryKey(),