Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "username_redirects" (
"id" TEXT NOT NULL,
"old_username" TEXT NOT NULL,
"new_username" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "username_redirects_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "username_redirects_old_username_key" ON "username_redirects"("old_username");

-- CreateIndex
CREATE INDEX "username_redirects_old_username_idx" ON "username_redirects"("old_username");

-- AddForeignKey
ALTER TABLE "username_redirects" ADD CONSTRAINT "username_redirects_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 14 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model User {
attendedEvents EventAttendee[]
ownedTeams Team[] @relation("TeamOwner")
teamMemberships TeamMember[] @relation("TeamMember")
usernameRedirects UsernameRedirect[]

@@map("users")
}
Expand Down Expand Up @@ -260,4 +261,17 @@ model TeamMember{
@@unique([userId, teamId])
@@index([userId])
@@map("team_members")
}

model UsernameRedirect {
id String @id @default(uuid())
oldUsername String @unique @map("old_username")
newUsername String @map("new_username")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([oldUsername])
@@map("username_redirects")
}
152 changes: 152 additions & 0 deletions apps/backend/src/__tests__/redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';

Check failure on line 2 in apps/backend/src/__tests__/redirects.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` import should occur before import of `vitest`

Check failure on line 2 in apps/backend/src/__tests__/redirects.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { publicRoutes } from '../routes/public.js';

Check failure on line 3 in apps/backend/src/__tests__/redirects.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import type { PrismaClient } from '@prisma/client';

const mockPrisma = {
usernameRedirect: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
cardView: {
create: vi.fn().mockReturnValue({ catch: vi.fn() }),
},
followLog: {
findMany: vi.fn().mockResolvedValue([]),
},
};

async function buildApp() {

Check warning on line 21 in apps/backend/src/__tests__/redirects.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify();
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
app.register(publicRoutes, { prefix: '/api/public' });
await app.ready();
return app;
}

describe('Username Redirects Routing', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('performs a 301 redirect to the new username for recently changed usernames', async () => {
const app = buildApp();
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
if (where.oldUsername === 'olduser') {
return Promise.resolve({
oldUsername: 'olduser',
newUsername: 'newuser',
createdAt: new Date(),
});
}
return Promise.resolve(null);
});

const appInstance = await app;
const res = await appInstance.inject({
method: 'GET',
url: '/api/public/olduser',
});

expect(res.statusCode).toBe(301);
expect(res.headers.location).toBe('/api/public/newuser');
});

it('does not redirect and returns 404/200 if username is not in redirects', async () => {
const app = buildApp();
mockPrisma.usernameRedirect.findUnique.mockResolvedValue(null);
mockPrisma.user.findUnique.mockResolvedValue(null);

const appInstance = await app;
const res = await appInstance.inject({
method: 'GET',
url: '/api/public/nonexistent',
});

expect(res.statusCode).toBe(404);
});

it('does not redirect if the redirect is older than 90 days', async () => {
const app = buildApp();
const ninetyOneDaysAgo = new Date();
ninetyOneDaysAgo.setDate(ninetyOneDaysAgo.getDate() - 91);

mockPrisma.usernameRedirect.findUnique.mockResolvedValue({
oldUsername: 'olduser',
newUsername: 'newuser',
createdAt: ninetyOneDaysAgo,
});
mockPrisma.user.findUnique.mockResolvedValue(null);

const appInstance = await app;
const res = await appInstance.inject({
method: 'GET',
url: '/api/public/olduser',
});

expect(res.statusCode).toBe(404);
});

it('resolves multi-step redirect chains recursively', async () => {
const app = buildApp();
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
if (where.oldUsername === 'userA') {
return Promise.resolve({
oldUsername: 'userA',
newUsername: 'userB',
createdAt: new Date(),
});
}
if (where.oldUsername === 'userB') {
return Promise.resolve({
oldUsername: 'userB',
newUsername: 'userC',
createdAt: new Date(),
});
}
return Promise.resolve(null);
});

const appInstance = await app;
const res = await appInstance.inject({
method: 'GET',
url: '/api/public/userA/qr?size=300',
});

expect(res.statusCode).toBe(301);
expect(res.headers.location).toBe('/api/public/userC/qr?size=300');
});

it('guards against infinite loops in redirect chains', async () => {
const app = buildApp();
mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => {
if (where.oldUsername === 'userA') {
return Promise.resolve({
oldUsername: 'userA',
newUsername: 'userB',
createdAt: new Date(),
});
}
if (where.oldUsername === 'userB') {
return Promise.resolve({
oldUsername: 'userB',
newUsername: 'userA',
createdAt: new Date(),
});
}
return Promise.resolve(null);
});
mockPrisma.user.findUnique.mockResolvedValue(null);

const appInstance = await app;
const res = await appInstance.inject({
method: 'GET',
url: '/api/public/userA',
});

expect(res.statusCode).toBe(301);
expect(res.headers.location).toBe('/api/public/userB');
});
});
42 changes: 42 additions & 0 deletions apps/backend/src/routes/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,48 @@ const MAX_QR_SIZE = 2048;
const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60';

export async function publicRoutes(app: FastifyInstance): Promise<void> {
// ─── Username Redirect Hook ───
app.addHook('preHandler', async (request, reply) => {
const params = request.params as Record<string, string> | undefined;
if (!params || !params.username) {
return;
}

const { username } = params;

const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);

let current = username;
let redirect = await app.prisma.usernameRedirect.findUnique({
where: { oldUsername: current },
});

const visited = new Set<string>();

while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(redirect.newUsername)) {
visited.add(current);
current = redirect.newUsername;
redirect = await app.prisma.usernameRedirect.findUnique({
where: { oldUsername: current },
});
}

if (current !== username) {
const urlParts = request.url.split('?');
const path = urlParts[0];
const query = urlParts[1] ? `?${urlParts[1]}` : '';

const pathSegments = path.split('/');
const index = pathSegments.indexOf(username);
if (index !== -1) {
pathSegments[index] = current;
const newPath = pathSegments.join('/') + query;
return reply.status(301).redirect(newPath);
}
}
});

// ─── Public Profile ───────────────────────────────────────────────────────
/**
* GET /api/u/:username
Expand Down
30 changes: 27 additions & 3 deletions apps/backend/src/services/profileService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify'

Check failure on line 1 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` type import should occur after import of `../utils/error.util.js`

Check failure on line 1 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { getProfileUrl } from '@devcard/shared/src/platforms.js'

Check failure on line 2 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { getErrorMessage } from '../utils/error.util.js'

export async function getOwnProfile(app: FastifyInstance, userId: string) {

Check warning on line 5 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const user = await app.prisma.user.findUnique({
where: { id: userId },
include: {
Expand All @@ -11,27 +11,51 @@
},
})

if (!user) return null

Check failure on line 14 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition

const { provider, providerId, ...profileData } = user as any

Check failure on line 16 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'providerId' is assigned a value but never used. Allowed unused vars must match /^_/u

Check failure on line 16 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

'provider' is assigned a value but never used. Allowed unused vars must match /^_/u
return { ...profileData, defaultCardId: user.cards[0]?.id || null }
}

export async function updateProfile(app: FastifyInstance, userId: string, data: any) {

Check warning on line 20 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
// Fast-path uniqueness check
if (data.username) {
const existing = await app.prisma.user.findFirst({
where: { username: data.username, NOT: { id: userId } },
})
if (existing) throw Object.assign(new Error('Username taken'), { code: 'P2002' })

Check failure on line 26 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
}

const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } })

try {
const response = await app.prisma.user.update({ where: { id: userId }, data, select: {
id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true
} })
const isUsernameChanging = data.username && currentUser && data.username !== currentUser.username;

const response = await app.prisma.$transaction(async (tx) => {
if (isUsernameChanging) {
// Delete any existing redirects where the oldUsername is the new username
await tx.usernameRedirect.deleteMany({
where: { oldUsername: data.username },
});

// Record the redirect from the old username to the new username
await tx.usernameRedirect.create({
data: {
oldUsername: currentUser.username,
newUsername: data.username,
userId,
},
});
}

return tx.user.update({
where: { id: userId },
data,
select: {
id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true
}
});
});

if (app.redis && currentUser) {
app.redis.del(`profile:${currentUser.username}`).catch((err: unknown) =>
Expand All @@ -47,27 +71,27 @@
}
}

export async function createPlatformLink(app: FastifyInstance, userId: string, linkData: any) {

Check warning on line 74 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const url = linkData.url || getProfileUrl(linkData.platform, linkData.username)
const maxOrder = await app.prisma.platformLink.aggregate({ where: { userId }, _max: { displayOrder: true } })
return app.prisma.platformLink.create({ data: { userId, platform: linkData.platform, username: linkData.username, url, displayOrder: (maxOrder._max.displayOrder ?? -1) + 1 } })
}

export async function updatePlatformLink(app: FastifyInstance, userId: string, id: string, linkData: any) {

Check warning on line 80 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } })
if (!existing) return null
const url = linkData.url || getProfileUrl(linkData.platform, linkData.username)
return app.prisma.platformLink.update({ where: { id }, data: { platform: linkData.platform, username: linkData.username, url } })
}

export async function deletePlatformLink(app: FastifyInstance, userId: string, id: string) {

Check warning on line 87 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } })
if (!existing) return false
await app.prisma.platformLink.delete({ where: { id } })
return true
}

export async function reorderLinks(app: FastifyInstance, userId: string, links: Array<{ id: string; displayOrder: number }>) {

Check warning on line 94 in apps/backend/src/services/profileService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
await app.prisma.$transaction(links.map((link) => app.prisma.platformLink.updateMany({ where: { id: link.id, userId }, data: { displayOrder: link.displayOrder } })))
return { message: 'Links reordered' }
}
6 changes: 5 additions & 1 deletion apps/web/src/pages/ProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { PLATFORMS, getProfileUrl } from '../shared';
import type { PublicProfile } from '../shared';
import { apiFetch } from '../lib/api';
Expand All @@ -15,6 +15,7 @@ const platformColors: Record<string, string> = {

export default function ProfilePage() {
const { username } = useParams<{ username: string }>();
const navigate = useNavigate();
const [profile, setProfile] = useState<PublicProfile | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -33,6 +34,9 @@ export default function ProfilePage() {
.then((data) => {
setProfile(data);
setError(null);
if (data.username && data.username !== username) {
navigate(`/u/${data.username}`, { replace: true });
}
})
.catch(() => {
setProfile(null);
Expand Down
Loading