feat: customize public profile visibility

This commit is contained in:
Vakanaut
2026-07-12 16:57:43 +03:00
parent 103e3ca209
commit d4c72115d8
19 changed files with 245 additions and 68 deletions

View File

@@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_username TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_future_races_public BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_completed_races_public BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET is_future_races_public = is_profile_public,
is_completed_races_public = is_profile_public
WHERE is_profile_public = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS users_profile_username_normalized_key
ON users (LOWER(profile_username))
WHERE profile_username IS NOT NULL;

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.5.1",
"version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.5.1",
"version": "1.5.2",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.5.1",
"version": "1.5.2",
"private": true,
"scripts": {
"build": "tsc",

View File

@@ -10,7 +10,9 @@ declare global {
id: string;
email: string;
emailVerifiedAt: string | null;
isProfilePublic: boolean;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
};
csrfTokenHash: string;
sessionToken: string;

View File

@@ -18,7 +18,9 @@ export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
isProfilePublic: boolean;
profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}
interface UserRow {
@@ -26,7 +28,9 @@ interface UserRow {
email: string;
password_hash: string;
email_verified_at: Date | string | null;
is_profile_public: boolean;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionRow {
@@ -37,7 +41,9 @@ interface SessionRow {
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
is_profile_public: boolean;
profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
}
interface SessionListRow {
@@ -68,7 +74,9 @@ function userFromRow(row: UserRow): AuthUser {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
};
}
@@ -85,7 +93,9 @@ function isUniqueViolation(error: unknown): boolean {
export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>(
"SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1",
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1`,
[normalized],
);
return rows[0] ?? null;
@@ -146,7 +156,8 @@ export async function registerUser(email: string, password: string): Promise<voi
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at, is_profile_public`,
RETURNING id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public`,
[normalized, passwordHash],
));
} catch (error) {
@@ -185,7 +196,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT is_profile_public FROM users WHERE id = $1) AS is_profile_public`,
(SELECT profile_username FROM users WHERE id = $1) AS profile_username,
(SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public,
(SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
@@ -197,7 +210,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
};
}
@@ -312,7 +327,8 @@ export async function rotateCsrf(sessionToken: string): Promise<string | null> {
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, u.is_profile_public
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at,
u.profile_username, u.is_future_races_public, u.is_completed_races_public
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
@@ -331,16 +347,38 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public,
profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
},
csrfTokenHash: row.csrf_token_hash,
};
}
export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise<void> {
export async function updateProfile(
userId: string,
profile: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
},
): Promise<void> {
await pool.query(
"UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1",
[userId, isProfilePublic],
`UPDATE users SET
profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END,
is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END,
is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END,
updated_at = NOW()
WHERE id = $1`,
[
userId,
profile.profileUsername !== undefined,
profile.profileUsername,
profile.isFutureRacesPublic !== undefined,
profile.isFutureRacesPublic,
profile.isCompletedRacesPublic !== undefined,
profile.isCompletedRacesPublic,
],
);
}

View File

@@ -13,7 +13,7 @@ import {
revokeSessionById,
rotateCsrf,
revokeSession,
setProfileVisibility,
updateProfile,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
@@ -80,7 +80,12 @@ const passwordChangeSchema = z.object({
}).strict();
const sessionIdSchema = z.string().uuid();
const profileSchema = z.object({ isProfilePublic: z.boolean() }).strict();
const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
const profileSchema = z.object({
profileUsername: profileUsernameSchema.nullable().optional(),
isFutureRacesPublic: z.boolean().optional(),
isCompletedRacesPublic: z.boolean().optional(),
}).strict().refine((profile) => Object.keys(profile).length > 0);
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
@@ -156,9 +161,13 @@ router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, n
return;
}
try {
await setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic);
res.json({ isProfilePublic: parsed.data.isProfilePublic });
await updateProfile(req.auth!.user.id, parsed.data);
res.json({ ok: true });
} catch (error) {
if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") {
res.status(409).json({ error: "username_taken", details: ["Username is already in use"] });
return;
}
next(error);
}
});

View File

@@ -10,22 +10,27 @@ interface PublicRaceRow {
distance_km: string;
status: "planned" | "registered" | "completed" | null;
cover_image_url: string | null;
finish_time: string | null;
finish_place: string | null;
}
function raceDate(value: string | Date): string {
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
router.get("/users/:id/races", async (req: Request, res: Response) => {
const parsed = z.string().uuid().safeParse(req.params.id);
router.get("/users/:username/races", async (req: Request, res: Response) => {
const parsed = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).safeParse(req.params.username);
if (!parsed.success) {
res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] });
res.status(400).json({ error: "validation_error", details: ["username: Invalid username"] });
return;
}
try {
const profile = await pool.query(
"SELECT id FROM users WHERE id = $1 AND is_profile_public = TRUE",
`SELECT id, is_future_races_public, is_completed_races_public
FROM users
WHERE LOWER(profile_username) = $1
AND (is_future_races_public = TRUE OR is_completed_races_public = TRUE)`,
[parsed.data],
);
if (profile.rowCount === 0) {
@@ -33,9 +38,12 @@ router.get("/users/:id/races", async (req: Request, res: Response) => {
return;
}
const { rows } = await pool.query<PublicRaceRow>(
`SELECT race_date, title, distance_km, status, cover_image_url
FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`,
[parsed.data],
`SELECT race_date, title, distance_km, status, cover_image_url, finish_time, finish_place
FROM races
WHERE owner_user_id = $1
AND ((status = 'completed' AND $2) OR (status IS DISTINCT FROM 'completed' AND $3))
ORDER BY race_date ASC`,
[profile.rows[0].id, profile.rows[0].is_completed_races_public, profile.rows[0].is_future_races_public],
);
res.json(rows.map((row) => ({
date: raceDate(row.race_date),
@@ -43,9 +51,11 @@ router.get("/users/:id/races", async (req: Request, res: Response) => {
distanceKm: Number(row.distance_km),
status: row.status,
coverImageUrl: row.cover_image_url,
finishTime: row.status === "completed" ? row.finish_time : null,
finishPlace: row.status === "completed" ? row.finish_place : null,
})));
} catch (error) {
console.error("[GET /users/:id/races]", error);
console.error("[GET /users/:username/races]", error);
res.status(503).json({ error: "database_unavailable" });
}
});

View File

@@ -173,46 +173,67 @@ test("GET /api/races requires authentication", async () => {
assert.equal(res.body.error, "unauthorized");
});
test("public profile exposes only its owner's calendar after they enable it", async () => {
test("public profile uses its username and separate visibility settings", async () => {
const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200);
const userId = user.body.user.id as string;
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-08-01-public-race",
slug: "2026-08-01-planned-race",
date: "2026-08-01",
title: "Public Race",
title: "Planned Race",
distanceKm: 10,
notes: "Private note",
bibNumber: "123",
})
.expect(201);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-07-01-completed-race",
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
finishTime: "00:40:00",
finishPlace: "12",
notes: "Private note",
})
.expect(201);
await request(app).get(`/api/users/${userId}/races`).expect(404);
await request(app).get("/api/users/public-runner/races").expect(404);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: true })
.send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false })
.expect(200);
const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200);
assert.deepEqual(publicRaces.body, [{
const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(futureRaces.body, [{
date: "2026-08-01",
title: "Public Race",
title: "Planned Race",
distanceKm: 10,
status: null,
coverImageUrl: null,
finishTime: null,
finishPlace: null,
}]);
await agent
.patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: false })
.send({ isFutureRacesPublic: false, isCompletedRacesPublic: true })
.expect(200);
await request(app).get(`/api/users/${userId}/races`).expect(404);
const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(completedRaces.body, [{
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
coverImageUrl: null,
finishTime: "00:40:00",
finishPlace: "12",
}]);
});
test("login uses generic response for missing user and wrong password", async () => {