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 [postalCode, setPostalCode] = useState("");
|
||||||
const [city, setCity] = useState("");
|
const [city, setCity] = useState("");
|
||||||
const [image, setImage] = useState<string | null>(null);
|
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[]>([
|
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>([
|
||||||
"vegan",
|
"vegan",
|
||||||
"vegetarian",
|
"vegetarian",
|
||||||
|
|
@ -177,9 +173,6 @@ export default function EinstellungenPage() {
|
||||||
setPostalCode(profileQuery.data.postalCode ?? "");
|
setPostalCode(profileQuery.data.postalCode ?? "");
|
||||||
setCity(profileQuery.data.city ?? "");
|
setCity(profileQuery.data.city ?? "");
|
||||||
setImage(profileQuery.data.image ?? null);
|
setImage(profileQuery.data.image ?? null);
|
||||||
setUsername(profileQuery.data.username ?? "");
|
|
||||||
setBio(profileQuery.data.bio ?? "");
|
|
||||||
setProfileVisible(profileQuery.data.profileVisible ?? false);
|
|
||||||
}
|
}
|
||||||
}, [profileQuery.data]);
|
}, [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({
|
const changeMyPassword = trpc.users.changeMyPassword.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
|
|
@ -597,75 +562,6 @@ export default function EinstellungenPage() {
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
) : null}
|
) : 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" ? (
|
{active === "ernaehrung" ? (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Rezept- und Mahlzeiten-Präferenzen"
|
title="Rezept- und Mahlzeiten-Präferenzen"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ClubActivityCategory,
|
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 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="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">
|
<div className="min-w-0 flex-1">
|
||||||
{entry.handleSlug ? (
|
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
|
||||||
<Link
|
|
||||||
href={`/profil/${entry.handleSlug}`}
|
|
||||||
className="truncate text-sm font-black text-[#0F766E] underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
{entry.name}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
|
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<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 { schema } from "@hellth/db";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { handleToSlug } from "../../../lib/handle";
|
|
||||||
import { platformProcedure, protectedProcedure, router } from "../../init";
|
import { platformProcedure, protectedProcedure, router } from "../../init";
|
||||||
import {
|
import {
|
||||||
auditHealthMutation,
|
auditHealthMutation,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { schema } from "@hellth/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { handleToSlug } from "../../../lib/handle";
|
|
||||||
import { protectedProcedure, router } from "../../init";
|
import { protectedProcedure, router } from "../../init";
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
|
@ -429,32 +428,17 @@ export const trackingRouter = router({
|
||||||
const users = userIds.length
|
const users = userIds.length
|
||||||
? await ctx.db.query.user.findMany({
|
? await ctx.db.query.user.findMany({
|
||||||
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
|
where: (row, { inArray: rowInArray }) => rowInArray(row.id, userIds),
|
||||||
columns: {
|
columns: { id: true, name: true, email: true },
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
username: true,
|
|
||||||
discriminator: true,
|
|
||||||
profileVisible: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const userById = new Map(users.map((user) => [user.id, user]));
|
const userById = new Map(users.map((user) => [user.id, user]));
|
||||||
const scores = new Map<
|
const scores = new Map<string, { userKey: string; name: string; points: number; completed: number }>();
|
||||||
string,
|
|
||||||
{ userKey: string; name: string; handleSlug: string | null; points: number; completed: number }
|
|
||||||
>();
|
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
if (log.points <= 0) continue;
|
if (log.points <= 0) continue;
|
||||||
const user = userById.get(log.userId);
|
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) ?? {
|
const current = scores.get(log.userId) ?? {
|
||||||
userKey: log.userId,
|
userKey: log.userId,
|
||||||
name: user?.name || "Hellth User",
|
name: user?.name || user?.email || "Hellth User",
|
||||||
handleSlug,
|
|
||||||
points: 0,
|
points: 0,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import { schema } from "@hellth/db";
|
import { schema } from "@hellth/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
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 { 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,
|
|
||||||
parseHandleSlug,
|
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -28,17 +19,6 @@ 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({
|
||||||
|
|
@ -50,10 +30,6 @@ 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,
|
||||||
|
|
@ -74,7 +50,6 @@ export const usersRouter = router({
|
||||||
street: null,
|
street: null,
|
||||||
postalCode: null,
|
postalCode: null,
|
||||||
city: null,
|
city: null,
|
||||||
handle: formatHandle(user.username, user.discriminator),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -107,199 +82,6 @@ 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 };
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Ö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
|
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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
"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,55 +1,26 @@
|
||||||
// Better Auth erwartet genau diese Tabellen und Spaltennamen
|
// Better Auth erwartet genau diese Tabellen und Spaltennamen
|
||||||
import {
|
import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
boolean,
|
|
||||||
index,
|
|
||||||
integer,
|
|
||||||
pgTable,
|
|
||||||
text,
|
|
||||||
timestamp,
|
|
||||||
uniqueIndex,
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
export const user = pgTable(
|
export const user = pgTable("user", {
|
||||||
"user",
|
id: text("id").primaryKey(),
|
||||||
{
|
name: text("name").notNull(),
|
||||||
id: text("id").primaryKey(),
|
email: text("email").notNull().unique(),
|
||||||
name: text("name").notNull(),
|
role: text("role").notNull().default("user"),
|
||||||
email: text("email").notNull().unique(),
|
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
||||||
role: text("role").notNull().default("user"),
|
twoFactorRecoveryRequired: boolean("twoFactorRecoveryRequired")
|
||||||
twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
|
.notNull()
|
||||||
twoFactorRecoveryRequired: boolean("twoFactorRecoveryRequired")
|
.default(false),
|
||||||
.notNull()
|
twoFactorRecoveryStartedAt: timestamp("twoFactorRecoveryStartedAt"),
|
||||||
.default(false),
|
twoFactorRecoveryStartedBy: text("twoFactorRecoveryStartedBy"),
|
||||||
twoFactorRecoveryStartedAt: timestamp("twoFactorRecoveryStartedAt"),
|
banned: boolean("banned").notNull().default(false),
|
||||||
twoFactorRecoveryStartedBy: text("twoFactorRecoveryStartedBy"),
|
banReason: text("banReason"),
|
||||||
banned: boolean("banned").notNull().default(false),
|
banExpires: timestamp("banExpires"),
|
||||||
banReason: text("banReason"),
|
emailVerified: boolean("emailVerified").notNull().default(false),
|
||||||
banExpires: timestamp("banExpires"),
|
image: text("image"),
|
||||||
emailVerified: boolean("emailVerified").notNull().default(false),
|
theme: text("theme"),
|
||||||
image: text("image"),
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
theme: text("theme"),
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
// 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", {
|
export const session = pgTable("session", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue