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 { 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 }) => {
|
||||
|
|
|
|||
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,
|
||||
"tag": "0018_outgoing_betty_ross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1780134873711,
|
||||
"tag": "0019_community_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue