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>
This commit is contained in:
Sebastian Mayer 2026-05-30 11:57:24 +02:00
parent 61a3f980bf
commit 634e67c602
7 changed files with 5081 additions and 21 deletions

View file

@ -0,0 +1,98 @@
// 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 };
}

View file

@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BIO_MAX_LENGTH,
DISCRIMINATOR_ALPHABET,
DISCRIMINATOR_LENGTH,
formatHandle,
generateDiscriminator,
generateUniqueDiscriminator,
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", "A12B"), "Held#A12B");
});
test("formatHandle liefert null ohne Username", () => {
assert.equal(formatHandle(null, "A12B"), 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);
});

View file

@ -3,6 +3,14 @@ import { TRPCError } from "@trpc/server";
import { asc, eq, 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,
validateUsername,
} from "../../lib/handle";
import { auditSecurityEvent } from "../../lib/security-audit";
import { platformProcedure, protectedProcedure, router } from "../init";
@ -19,6 +27,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 +49,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 +73,7 @@ export const usersRouter = router({
street: null,
postalCode: null,
city: null,
handle: formatHandle(user.username, user.discriminator),
};
}),
@ -82,6 +106,114 @@ 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 };
}),
setTheme: protectedProcedure
.input(z.object({ theme: z.enum(["dark", "light"]).nullable() }))
.mutation(async ({ ctx, input }) => {

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,26 +1,55 @@
// 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", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: text("role").notNull().default("user"),
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
twoFactorRecoveryRequired: boolean("twoFactorRecoveryRequired")
.notNull()
.default(false),
twoFactorRecoveryStartedAt: timestamp("twoFactorRecoveryStartedAt"),
twoFactorRecoveryStartedBy: text("twoFactorRecoveryStartedBy"),
banned: boolean("banned").notNull().default(false),
banReason: text("banReason"),
banExpires: timestamp("banExpires"),
emailVerified: boolean("emailVerified").notNull().default(false),
image: text("image"),
theme: text("theme"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
role: text("role").notNull().default("user"),
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
twoFactorRecoveryRequired: boolean("twoFactorRecoveryRequired")
.notNull()
.default(false),
twoFactorRecoveryStartedAt: timestamp("twoFactorRecoveryStartedAt"),
twoFactorRecoveryStartedBy: text("twoFactorRecoveryStartedBy"),
banned: boolean("banned").notNull().default(false),
banReason: text("banReason"),
banExpires: timestamp("banExpires"),
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(),