Feature: Fremdprofil-API (publicProfileByHandle) + Handle-Slug-Helfer

Backend fuer oeffentliche Community-Fremdprofile (Stufe 2 der Roadmap).

handle.ts:
- handleToSlug: KochHeld#A23B -> KochHeld-A23B (URL-tauglich, '#' geht
  nicht im Pfad).
- parseHandleSlug: zerlegt am LETZTEN Bindestrich (Username darf Bindestriche
  enthalten), validiert Username + Discriminator gegen das Alphabet.

users.publicProfileByHandle (protectedProcedure):
- DATENSCHUTZ: liefert nur bei profileVisible=true, sonst NOT_FOUND (privates
  und nicht existierendes Profil sind von aussen ununterscheidbar).
- Strikte Feld-Whitelist: handle, username, discriminator, bio, image.
  NIEMALS Art.-9-Daten (Gewicht/Kalorien/Ziele), keine Kontakt-/Auth-Felder.
- Aggregat-Punkte gesamt + 30 Tage aus activity_logs.points, plus
  anonymisierte Aktivitaets-Streak (distinkte aktive Tage der letzten 7).

Tests (node:test): 9 Slug-Tests + 6 Endpoint-Tests (sichtbar, Leak-Schutz,
NOT_FOUND bei privat/unbekannt/ungueltigem Slug, Auth-Pflicht).
Verifiziert: 75 API-Tests gruen, Typecheck 3/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastian Mayer 2026-05-30 12:50:50 +02:00
parent 9ab6db9c0e
commit 2dfc770950
4 changed files with 328 additions and 3 deletions

View file

@ -96,3 +96,39 @@ export function validateUsername(raw: string): UsernameValidation {
}
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 };
}

View file

@ -7,6 +7,8 @@ import {
formatHandle,
generateDiscriminator,
generateUniqueDiscriminator,
handleToSlug,
parseHandleSlug,
validateUsername,
} from "../lib/handle";
@ -65,11 +67,11 @@ test("generateUniqueDiscriminator wirft, wenn nach maxAttempts kein freier gefun
});
test("formatHandle baut Username#Discriminator", () => {
assert.equal(formatHandle("Held", "A12B"), "Held#A12B");
assert.equal(formatHandle("Held", "A23B"), "Held#A23B");
});
test("formatHandle liefert null ohne Username", () => {
assert.equal(formatHandle(null, "A12B"), null);
assert.equal(formatHandle(null, "A23B"), null);
});
test("formatHandle liefert null ohne Discriminator", () => {
@ -100,3 +102,50 @@ test("validateUsername lehnt Umlaute ab (Handle bleibt URL-tauglich)", () => {
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",
});
});

View file

@ -1,6 +1,6 @@
import { schema } from "@hellth/db";
import { TRPCError } from "@trpc/server";
import { asc, eq, sql } from "drizzle-orm";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { z } from "zod";
import { auth } from "../../lib/auth";
import {
@ -9,6 +9,7 @@ import {
USERNAME_MIN_LENGTH,
formatHandle,
generateUniqueDiscriminator,
parseHandleSlug,
validateUsername,
} from "../../lib/handle";
import { auditSecurityEvent } from "../../lib/security-audit";
@ -214,6 +215,91 @@ export const usersRouter = router({
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 }) => {

View file

@ -0,0 +1,154 @@
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;
},
);
});