Skip to content

Commit e1f3804

Browse files
author
Test User
committed
feat(profile): store previous usernames and 301 redirect old public URLs to new username
1 parent f819079 commit e1f3804

6 files changed

Lines changed: 259 additions & 4 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "username_redirects" (
3+
"id" TEXT NOT NULL,
4+
"old_username" TEXT NOT NULL,
5+
"new_username" TEXT NOT NULL,
6+
"user_id" TEXT NOT NULL,
7+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
9+
CONSTRAINT "username_redirects_pkey" PRIMARY KEY ("id")
10+
);
11+
12+
-- CreateIndex
13+
CREATE UNIQUE INDEX "username_redirects_old_username_key" ON "username_redirects"("old_username");
14+
15+
-- CreateIndex
16+
CREATE INDEX "username_redirects_old_username_idx" ON "username_redirects"("old_username");
17+
18+
-- AddForeignKey
19+
ALTER TABLE "username_redirects" ADD CONSTRAINT "username_redirects_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/backend/prisma/schema.prisma

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ model User {
4343
attendedEvents EventAttendee[]
4444
ownedTeams Team[] @relation("TeamOwner")
4545
teamMemberships TeamMember[] @relation("TeamMember")
46+
usernameRedirects UsernameRedirect[]
4647
4748
@@map("users")
4849
}
@@ -260,4 +261,17 @@ model TeamMember{
260261
@@unique([userId, teamId])
261262
@@index([userId])
262263
@@map("team_members")
264+
}
265+
266+
model UsernameRedirect {
267+
id String @id @default(uuid())
268+
oldUsername String @unique @map("old_username")
269+
newUsername String @map("new_username")
270+
userId String @map("user_id")
271+
createdAt DateTime @default(now()) @map("created_at")
272+
273+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
274+
275+
@@index([oldUsername])
276+
@@map("username_redirects")
263277
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import { publicRoutes } from '../routes/public.js';
4+
import type { PrismaClient } from '@prisma/client';
5+
6+
const mockPrisma = {
7+
usernameRedirect: {
8+
findUnique: vi.fn(),
9+
},
10+
user: {
11+
findUnique: vi.fn(),
12+
},
13+
cardView: {
14+
create: vi.fn().mockReturnValue({ catch: vi.fn() }),
15+
},
16+
followLog: {
17+
findMany: vi.fn().mockResolvedValue([]),
18+
},
19+
};
20+
21+
async function buildApp() {
22+
const app = Fastify();
23+
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
24+
app.register(publicRoutes, { prefix: '/api/public' });
25+
await app.ready();
26+
return app;
27+
}
28+
29+
describe('Username Redirects Routing', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
it('performs a 301 redirect to the new username for recently changed usernames', async () => {
35+
const app = buildApp();
36+
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
37+
if (where.oldUsername === 'olduser') {
38+
return Promise.resolve({
39+
oldUsername: 'olduser',
40+
newUsername: 'newuser',
41+
createdAt: new Date(),
42+
});
43+
}
44+
return Promise.resolve(null);
45+
});
46+
47+
const appInstance = await app;
48+
const res = await appInstance.inject({
49+
method: 'GET',
50+
url: '/api/public/olduser',
51+
});
52+
53+
expect(res.statusCode).toBe(301);
54+
expect(res.headers.location).toBe('/api/public/newuser');
55+
});
56+
57+
it('does not redirect and returns 404/200 if username is not in redirects', async () => {
58+
const app = buildApp();
59+
mockPrisma.usernameRedirect.findUnique.mockResolvedValue(null);
60+
mockPrisma.user.findUnique.mockResolvedValue(null);
61+
62+
const appInstance = await app;
63+
const res = await appInstance.inject({
64+
method: 'GET',
65+
url: '/api/public/nonexistent',
66+
});
67+
68+
expect(res.statusCode).toBe(404);
69+
});
70+
71+
it('does not redirect if the redirect is older than 90 days', async () => {
72+
const app = buildApp();
73+
const ninetyOneDaysAgo = new Date();
74+
ninetyOneDaysAgo.setDate(ninetyOneDaysAgo.getDate() - 91);
75+
76+
mockPrisma.usernameRedirect.findUnique.mockResolvedValue({
77+
oldUsername: 'olduser',
78+
newUsername: 'newuser',
79+
createdAt: ninetyOneDaysAgo,
80+
});
81+
mockPrisma.user.findUnique.mockResolvedValue(null);
82+
83+
const appInstance = await app;
84+
const res = await appInstance.inject({
85+
method: 'GET',
86+
url: '/api/public/olduser',
87+
});
88+
89+
expect(res.statusCode).toBe(404);
90+
});
91+
92+
it('resolves multi-step redirect chains recursively', async () => {
93+
const app = buildApp();
94+
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
95+
if (where.oldUsername === 'userA') {
96+
return Promise.resolve({
97+
oldUsername: 'userA',
98+
newUsername: 'userB',
99+
createdAt: new Date(),
100+
});
101+
}
102+
if (where.oldUsername === 'userB') {
103+
return Promise.resolve({
104+
oldUsername: 'userB',
105+
newUsername: 'userC',
106+
createdAt: new Date(),
107+
});
108+
}
109+
return Promise.resolve(null);
110+
});
111+
112+
const appInstance = await app;
113+
const res = await appInstance.inject({
114+
method: 'GET',
115+
url: '/api/public/userA/qr?size=300',
116+
});
117+
118+
expect(res.statusCode).toBe(301);
119+
expect(res.headers.location).toBe('/api/public/userC/qr?size=300');
120+
});
121+
122+
it('guards against infinite loops in redirect chains', async () => {
123+
const app = buildApp();
124+
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
125+
if (where.oldUsername === 'userA') {
126+
return Promise.resolve({
127+
oldUsername: 'userA',
128+
newUsername: 'userB',
129+
createdAt: new Date(),
130+
});
131+
}
132+
if (where.oldUsername === 'userB') {
133+
return Promise.resolve({
134+
oldUsername: 'userB',
135+
newUsername: 'userA',
136+
createdAt: new Date(),
137+
});
138+
}
139+
return Promise.resolve(null);
140+
});
141+
mockPrisma.user.findUnique.mockResolvedValue(null);
142+
143+
const appInstance = await app;
144+
const res = await appInstance.inject({
145+
method: 'GET',
146+
url: '/api/public/userA',
147+
});
148+
149+
expect(res.statusCode).toBe(301);
150+
expect(res.headers.location).toBe('/api/public/userB');
151+
});
152+
});

apps/backend/src/routes/public.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,48 @@ const MAX_QR_SIZE = 2048;
1111
const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60';
1212

1313
export async function publicRoutes(app: FastifyInstance): Promise<void> {
14+
// ─── Username Redirect Hook ───
15+
app.addHook('preHandler', async (request, reply) => {
16+
const params = request.params as Record<string, string> | undefined;
17+
if (!params || !params.username) {
18+
return;
19+
}
20+
21+
const { username } = params;
22+
23+
const ninetyDaysAgo = new Date();
24+
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
25+
26+
let current = username;
27+
let redirect = await app.prisma.usernameRedirect.findUnique({
28+
where: { oldUsername: current },
29+
});
30+
31+
const visited = new Set<string>();
32+
33+
while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(current)) {
34+
visited.add(current);
35+
current = redirect.newUsername;
36+
redirect = await app.prisma.usernameRedirect.findUnique({
37+
where: { oldUsername: current },
38+
});
39+
}
40+
41+
if (current !== username) {
42+
const urlParts = request.url.split('?');
43+
const path = urlParts[0];
44+
const query = urlParts[1] ? `?${urlParts[1]}` : '';
45+
46+
const pathSegments = path.split('/');
47+
const index = pathSegments.indexOf(username);
48+
if (index !== -1) {
49+
pathSegments[index] = current;
50+
const newPath = pathSegments.join('/') + query;
51+
return reply.status(301).redirect(newPath);
52+
}
53+
}
54+
});
55+
1456
// ─── Public Profile ───────────────────────────────────────────────────────
1557
/**
1658
* GET /api/u/:username

apps/backend/src/services/profileService.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,33 @@ export async function updateProfile(app: FastifyInstance, userId: string, data:
2929
const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } })
3030

3131
try {
32-
const response = await app.prisma.user.update({ where: { id: userId }, data, select: {
33-
id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true
34-
} })
32+
const isUsernameChanging = data.username && currentUser && data.username !== currentUser.username;
33+
34+
const response = await app.prisma.$transaction(async (tx) => {
35+
if (isUsernameChanging) {
36+
// Delete any existing redirects where the oldUsername is the new username
37+
await tx.usernameRedirect.deleteMany({
38+
where: { oldUsername: data.username },
39+
});
40+
41+
// Record the redirect from the old username to the new username
42+
await tx.usernameRedirect.create({
43+
data: {
44+
oldUsername: currentUser.username,
45+
newUsername: data.username,
46+
userId,
47+
},
48+
});
49+
}
50+
51+
return tx.user.update({
52+
where: { id: userId },
53+
data,
54+
select: {
55+
id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true
56+
}
57+
});
58+
});
3559

3660
if (app.redis && currentUser) {
3761
app.redis.del(`profile:${currentUser.username}`).catch((err: unknown) =>

apps/web/src/pages/ProfilePage.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react';
2-
import { useParams, Link } from 'react-router-dom';
2+
import { useParams, Link, useNavigate } from 'react-router-dom';
33
import { PLATFORMS, getProfileUrl } from '../shared';
44
import type { PublicProfile } from '../shared';
55
import { apiFetch } from '../lib/api';
@@ -15,6 +15,7 @@ const platformColors: Record<string, string> = {
1515

1616
export default function ProfilePage() {
1717
const { username } = useParams<{ username: string }>();
18+
const navigate = useNavigate();
1819
const [profile, setProfile] = useState<PublicProfile | null>(null);
1920
const [error, setError] = useState<string | null>(null);
2021
const [loading, setLoading] = useState(true);
@@ -33,6 +34,9 @@ export default function ProfilePage() {
3334
.then((data) => {
3435
setProfile(data);
3536
setError(null);
37+
if (data.username && data.username !== username) {
38+
navigate(`/u/${data.username}`, { replace: true });
39+
}
3640
})
3741
.catch(() => {
3842
setProfile(null);

0 commit comments

Comments
 (0)