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:
parent
61a3f980bf
commit
634e67c602
7 changed files with 5081 additions and 21 deletions
98
packages/api/src/lib/handle.ts
Normal file
98
packages/api/src/lib/handle.ts
Normal 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 };
|
||||||
|
}
|
||||||
102
packages/api/src/trpc/handle.test.ts
Normal file
102
packages/api/src/trpc/handle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,14 @@ import { TRPCError } from "@trpc/server";
|
||||||
import { asc, eq, sql } from "drizzle-orm";
|
import { asc, eq, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "../../lib/auth";
|
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 { auditSecurityEvent } from "../../lib/security-audit";
|
||||||
import { platformProcedure, protectedProcedure, router } from "../init";
|
import { platformProcedure, protectedProcedure, router } from "../init";
|
||||||
|
|
||||||
|
|
@ -19,6 +27,17 @@ const imageFieldSchema = z
|
||||||
|
|
||||||
const platformRoleSchema = z.enum(["user", "admin"]);
|
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({
|
export const usersRouter = router({
|
||||||
myProfile: protectedProcedure.query(async ({ ctx }) => {
|
myProfile: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.query.user.findFirst({
|
const user = await ctx.db.query.user.findFirst({
|
||||||
|
|
@ -30,6 +49,10 @@ export const usersRouter = router({
|
||||||
image: true,
|
image: true,
|
||||||
role: true,
|
role: true,
|
||||||
theme: true,
|
theme: true,
|
||||||
|
username: true,
|
||||||
|
discriminator: true,
|
||||||
|
bio: true,
|
||||||
|
profileVisible: true,
|
||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
twoFactorRecoveryRequired: true,
|
twoFactorRecoveryRequired: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
|
@ -50,6 +73,7 @@ export const usersRouter = router({
|
||||||
street: null,
|
street: null,
|
||||||
postalCode: null,
|
postalCode: null,
|
||||||
city: null,
|
city: null,
|
||||||
|
handle: formatHandle(user.username, user.discriminator),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -82,6 +106,114 @@ export const usersRouter = router({
|
||||||
return { ok: true };
|
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
|
setTheme: protectedProcedure
|
||||||
.input(z.object({ theme: z.enum(["dark", "light"]).nullable() }))
|
.input(z.object({ theme: z.enum(["dark", "light"]).nullable() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|
|
||||||
6
packages/db/drizzle/0019_community_profile.sql
Normal file
6
packages/db/drizzle/0019_community_profile.sql
Normal 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");
|
||||||
4686
packages/db/drizzle/meta/0019_snapshot.json
Normal file
4686
packages/db/drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -134,6 +134,13 @@
|
||||||
"when": 1780046007288,
|
"when": 1780046007288,
|
||||||
"tag": "0018_outgoing_betty_ross",
|
"tag": "0018_outgoing_betty_ross",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780134873711,
|
||||||
|
"tag": "0019_community_profile",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
// Better Auth erwartet genau diese Tabellen und Spaltennamen
|
// 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(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
|
|
@ -18,9 +28,28 @@ export const user = pgTable("user", {
|
||||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
theme: text("theme"),
|
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(),
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updatedAt").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", {
|
export const session = pgTable("session", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue