Compare commits
No commits in common. "0de4b9c9f8618e8e6e6947e5ac859175afe1641a" and "61a3f980bf28e52219f925fe719fdad1904a41ad" have entirely different histories.
0de4b9c9f8
...
61a3f980bf
13 changed files with 26 additions and 5655 deletions
|
|
@ -139,10 +139,6 @@ 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",
|
||||
|
|
@ -177,9 +173,6 @@ 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]);
|
||||
|
||||
|
|
@ -198,34 +191,6 @@ 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("");
|
||||
|
|
@ -597,75 +562,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
TrophyIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ClubActivityCategory,
|
||||
|
|
@ -437,16 +436,7 @@ 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="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">
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// 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 };
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
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",
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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,
|
||||
|
|
@ -429,32 +428,17 @@ 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,
|
||||
username: true,
|
||||
discriminator: true,
|
||||
profileVisible: true,
|
||||
},
|
||||
columns: { id: true, name: true, email: true },
|
||||
})
|
||||
: [];
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
const scores = new Map<
|
||||
string,
|
||||
{ userKey: string; name: string; handleSlug: string | null; points: number; completed: number }
|
||||
>();
|
||||
const scores = new Map<string, { userKey: string; name: string; 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 || "Hellth User",
|
||||
handleSlug,
|
||||
name: user?.name || user?.email || "Hellth User",
|
||||
points: 0,
|
||||
completed: 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { schema } from "@hellth/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||
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,
|
||||
parseHandleSlug,
|
||||
validateUsername,
|
||||
} from "../../lib/handle";
|
||||
import { auditSecurityEvent } from "../../lib/security-audit";
|
||||
import { platformProcedure, protectedProcedure, router } from "../init";
|
||||
|
||||
|
|
@ -28,17 +19,6 @@ 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({
|
||||
|
|
@ -50,10 +30,6 @@ export const usersRouter = router({
|
|||
image: true,
|
||||
role: true,
|
||||
theme: true,
|
||||
username: true,
|
||||
discriminator: true,
|
||||
bio: true,
|
||||
profileVisible: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorRecoveryRequired: true,
|
||||
createdAt: true,
|
||||
|
|
@ -74,7 +50,6 @@ export const usersRouter = router({
|
|||
street: null,
|
||||
postalCode: null,
|
||||
city: null,
|
||||
handle: formatHandle(user.username, user.discriminator),
|
||||
};
|
||||
}),
|
||||
|
||||
|
|
@ -107,199 +82,6 @@ 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 }) => {
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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
|
|
@ -134,13 +134,6 @@
|
|||
"when": 1780046007288,
|
||||
"tag": "0018_outgoing_betty_ross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1780134873711,
|
||||
"tag": "0019_community_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,55 +1,26 @@
|
|||
// Better Auth erwartet genau diese Tabellen und Spaltennamen
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { boolean, integer, pgTable, text, timestamp } 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"),
|
||||
// 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 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 session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue