From eb5635e201686f58b3a28ed52b6cf4fe24f9fb56 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Fri, 12 Jun 2026 19:12:14 +0530 Subject: [PATCH 1/3] fix(teams): prevent duplicate slug allocation under concurrent creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace non-deterministic random suffix generation with sequential numeric candidates (my-team → my-team-1 → my-team-2, capped at 10). Wrap team creation in a bounded retry loop (5 attempts) so P2002 constraint violations from concurrent inserts trigger re-allocation rather than surfacing as a 409. The database-level @unique constraint on Team.slug remains the authoritative guard; application logic now recovers gracefully when it fires. Adds slug utility tests (createSlug, generateUniqueSlug determinism and bounds) and team route tests for retry-on-race-condition and retry-exhaustion paths. Closes #499 --- apps/backend/src/__tests__/slug.test.ts | 74 +++ apps/backend/src/__tests__/team.test.ts | 52 +- apps/backend/src/routes/team.ts | 698 +++++++++++------------- apps/backend/src/utils/slug.ts | 36 +- 4 files changed, 475 insertions(+), 385 deletions(-) create mode 100644 apps/backend/src/__tests__/slug.test.ts diff --git a/apps/backend/src/__tests__/slug.test.ts b/apps/backend/src/__tests__/slug.test.ts new file mode 100644 index 00000000..fb43a6cf --- /dev/null +++ b/apps/backend/src/__tests__/slug.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createSlug, generateUniqueSlug } from '../utils/slug'; + +describe('createSlug', () => { + it('lowercases and trims input', () => { + expect(createSlug(' Hello World ')).toBe('hello-world'); + }); + + it('replaces spaces with hyphens', () => { + expect(createSlug('My Team Name')).toBe('my-team-name'); + }); + + it('strips non-alphanumeric characters', () => { + expect(createSlug('DevCard @Core!')).toBe('devcard-core'); + }); + + it('collapses multiple hyphens', () => { + expect(createSlug('a--b---c')).toBe('a-b-c'); + }); + + it('removes leading and trailing hyphens', () => { + expect(createSlug('--team--')).toBe('team'); + }); +}); + +describe('generateUniqueSlug', () => { + it('returns base slug when it is available', async () => { + const slugExists = vi.fn().mockResolvedValue(false); + const result = await generateUniqueSlug('My Team', slugExists); + expect(result).toBe('my-team'); + expect(slugExists).toHaveBeenCalledOnce(); + }); + + it('returns sequential numeric suffix when base slug is taken', async () => { + const slugExists = vi.fn() + .mockResolvedValueOnce(true) // my-team taken + .mockResolvedValueOnce(false); // my-team-1 free + const result = await generateUniqueSlug('My Team', slugExists); + expect(result).toBe('my-team-1'); + }); + + it('increments suffix deterministically until a free slot is found', async () => { + const slugExists = vi.fn() + .mockResolvedValueOnce(true) // my-team + .mockResolvedValueOnce(true) // my-team-1 + .mockResolvedValueOnce(true) // my-team-2 + .mockResolvedValueOnce(false); // my-team-3 free + const result = await generateUniqueSlug('My Team', slugExists); + expect(result).toBe('my-team-3'); + }); + + it('throws when all 10 suffix candidates are taken', async () => { + const slugExists = vi.fn().mockResolvedValue(true); + await expect(generateUniqueSlug('My Team', slugExists)).rejects.toThrow( + 'Unable to generate unique slug', + ); + expect(slugExists).toHaveBeenCalledTimes(11); // base + 10 suffixes + }); + + it('produces consistent slugs across concurrent calls for different inputs', async () => { + const takenSlugs = new Set(); + const slugExists = vi.fn(async (slug: string) => takenSlugs.has(slug)); + + const [a, b] = await Promise.all([ + generateUniqueSlug('Alpha Team', slugExists), + generateUniqueSlug('Beta Team', slugExists), + ]); + + expect(a).toBe('alpha-team'); + expect(b).toBe('beta-team'); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 350298a1..5e73c1a3 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -1,8 +1,12 @@ +import { Prisma, TeamRole } from '@prisma/client'; +import Fastify from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient, TeamRole } from '@prisma/client'; + import { teamRoutes } from '../routes/team'; +import type { PrismaClient } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + // ─── Shared mock data ───────────────────────────────────────────────────────── const MOCK_OWNER_ID = 'user-uuid-001'; @@ -92,7 +96,7 @@ const prismaMock = { // ─── App factory ────────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); @@ -118,7 +122,7 @@ async function createTeam( app: FastifyInstance, body: Record, authenticated = true, -) { +): Promise> { return app.inject({ method: 'POST', url: '/', @@ -220,6 +224,46 @@ describe('Teams API', () => { expect(res.statusCode).toBe(500); expect(res.json()).toMatchObject({ error: 'Failed to create team' }); }); + + it('201 — retries and succeeds when first attempt loses slug race to concurrent request', async () => { + // First generateUniqueSlug: base slug appears available + prismaMock.team.findUnique.mockResolvedValueOnce(null); + // First $transaction: P2002 — another request inserted first + prismaMock.$transaction.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: '0' }), + ); + // Second generateUniqueSlug: base slug now taken, devcard-core-1 is free + prismaMock.team.findUnique.mockResolvedValueOnce(MOCK_TEAM); // devcard-core taken + prismaMock.team.findUnique.mockResolvedValueOnce(null); // devcard-core-1 free + // Second $transaction: succeeds with suffix slug + prismaMock.$transaction.mockImplementationOnce(async (cb: any) => { + return cb({ + team: { create: vi.fn().mockResolvedValue({ ...MOCK_TEAM, slug: 'devcard-core-1' }) }, + teamMember: { create: vi.fn().mockResolvedValue({}) }, + }); + }); + + const res = await createTeam(app, validBody); + + expect(res.statusCode).toBe(201); + expect(res.json().slug).toBe('devcard-core-1'); + }); + + it('409 — exhausts all retry attempts when DB rejects every slug with P2002', async () => { + const p2002 = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`slug`)', + { code: 'P2002', clientVersion: '0' }, + ); + // Slug always appears available at the application level + prismaMock.team.findUnique.mockResolvedValue(null); + // DB always rejects with P2002 (concurrent inserts won every race) + prismaMock.$transaction.mockRejectedValue(p2002); + + const res = await createTeam(app, validBody); + + expect(res.statusCode).toBe(409); + expect(prismaMock.$transaction).toHaveBeenCalledTimes(5); + }); }); // ── GET /:slug — public team profile ───────────────────────────────────── diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index 29ebb003..d7abbfed 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -1,15 +1,15 @@ -import {Prisma, TeamRole } from '@prisma/client'; -import QRCode from 'qrcode' +import { Prisma, TeamRole } from '@prisma/client'; +import QRCode from 'qrcode'; -import {generateUniqueSlug} from '../utils/slug.js' -import { createTeamScehma,inviteMembers,updateTeam } from '../validations/team.validation.js'; +import { generateUniqueSlug } from '../utils/slug.js'; +import { createTeamScehma, inviteMembers, updateTeam } from '../validations/team.validation.js'; -import type {PlatformLink, PublicProfile} from '@devcard/shared' +import type { PlatformLink, PublicProfile } from '@devcard/shared'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; type TeamMember = PublicProfile & { - teamRole: TeamRole - joinedAt: Date; + teamRole: TeamRole; + joinedAt: Date; } type TeamProfile = { @@ -24,366 +24,330 @@ type TeamProfile = { members: TeamMember[]; } -export async function teamRoutes(app:FastifyInstance){ - app.post('/', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async(request:FastifyRequest<{ - Body: {name: string, description? : string, avatarUrl?: string } - }>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const parsed = createTeamScehma.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - }; - const {name , description , avatarUrl} = parsed.data; - - const finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.team.findUnique({where: {slug }}) - - return !!existing - }) - - try { - const team = await app.prisma.$transaction(async (tx) => { - const team = await tx.team.create({ - data: { - name, - slug: finalSlug, - description, - avatarUrl, - ownerId: userId, - } - }) - - await tx.teamMember.create({ - data: { - teamId : team.id, - userId, - role: TeamRole.OWNER, - joinedAt: new Date(), - } - }) - return team - }) - return reply.status(201).send(team) - - }catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - switch (error.code) { - case 'P2002': - return reply.status(409).send({ - error: 'Team slug already exists' - }); - - case 'P2003': - return reply.status(400).send({ - error: 'Invalid organizer' - }); - } - } - app.log.error('Failed to create a team'); - return reply.status(500).send({ - error: 'Failed to create team' - }); +export async function teamRoutes(app: FastifyInstance): Promise { + app.post('/', async (request: FastifyRequest<{ + Body: { name: string; description?: string; avatarUrl?: string }; + }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + + const userId = decoded.id; + const parsed = createTeamScehma.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } + const { name, description, avatarUrl } = parsed.data; + + const MAX_CREATE_ATTEMPTS = 5; + + for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt++) { + let finalSlug: string; + try { + finalSlug = await generateUniqueSlug(name, async (slug) => { + const existing = await app.prisma.team.findUnique({ where: { slug } }); + return !!existing; + }); + } catch { + return reply.status(409).send({ error: 'Unable to generate a unique team slug' }); + } + + try { + const team = await app.prisma.$transaction(async (tx) => { + const created = await tx.team.create({ + data: { name, slug: finalSlug, description, avatarUrl, ownerId: userId }, + }); + await tx.teamMember.create({ + data: { teamId: created.id, userId, role: TeamRole.OWNER, joinedAt: new Date() }, + }); + return created; + }); + return reply.status(201).send(team); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + continue; + } + if (error.code === 'P2003') { + return reply.status(400).send({ error: 'Invalid organizer' }); + } } - }) - - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - - try { - const details = await app.prisma.team.findUnique( - { - where: {slug: paramsSlug}, - include: { - members: { - include: { - user: { - include: { - platformLinks: true - } - } - } - } - } - } - ) - - if(!details){ - return reply.status(404).send({error: 'Team not found'}) - } - - const members = details.members.map((tm): TeamMember => ({ - username: tm.user.username, - displayName: tm.user.displayName, - bio: tm.user.bio, - pronouns: tm.user.pronouns, - role: tm.user.role, - company: tm.user.company, - avatarUrl: tm.user.avatarUrl, - accentColor: tm.user.accentColor, - links: tm.user.platformLinks.map((pl: PlatformLink) => ({ - id: pl.id, - platform: pl.platform, - username: pl.username, - url: pl.url, - displayOrder: pl.displayOrder, - })), - teamRole: tm.role, - joinedAt: tm.joinedAt, - - })) - - const response: TeamProfile = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - avatarUrl: details.avatarUrl, - ownerId: details.ownerId, - createdAt: details?.createdAt, - updatedAt: details.updatedAt, - members - } - - return response; - } catch (error) { - app.log.error(error); - return reply.status(500).send('Database query failed') - } - - }) - - app.post('/:slug/members', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async(request: FastifyRequest<{Params: {slug:string}, Body:{username:string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const userId = (request.user as any).id; - const parsed = inviteMembers.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - }; - const {username} = parsed.data; - try { - const teamDetails = await app.prisma.team.findUnique( - {where: {slug: paramsSlug }, - include:{ - owner: true, - members: { - include: { - user: true - } - } - } - } - ) - if(!teamDetails){ - return reply.status(404).send('Team not found'); - } - //Check request user is owner - if(teamDetails?.ownerId !== userId){ - return reply.status(403).send('Forbidden') - } - - const alreadyMember = teamDetails.members.find((u) => u.user.username === username) - - //Check invited username is not a member and owner; - if(alreadyMember || teamDetails.owner.username === username){ - return reply.status(409).send('Conflict') - } - - const invitedUserDetails = await app.prisma.user.findUnique(( - {where: { - username - }})) - - if(!invitedUserDetails){ - return reply.status(404).send('User not found') - } - - await app.prisma.teamMember.create({ - data: { - teamId: teamDetails.id, - userId: invitedUserDetails.id, - role: TeamRole.MEMBER, - joinedAt: new Date() - } - }) - - return reply.status(201).send('User invited') - - } catch (error) { - app.log.error(error); - return reply.status(500).send('Database query failed') - } - }) - - app.delete('/:slug/members/:userId', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string, userId: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug - const paramsUserId = request.params.userId - const userID = (request.user as any).id; - const teamDetails = await app.prisma.team.findUnique( - {where: {slug: paramsSlug}, + app.log.error('Failed to create a team'); + return reply.status(500).send({ error: 'Failed to create team' }); + } + } + + return reply.status(409).send({ error: 'Unable to allocate a unique team slug' }); + }); + + app.get('/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + + try { + const details = await app.prisma.team.findUnique({ + where: { slug: paramsSlug }, + include: { + members: { include: { - members: { - include:{ - user: true - } - } - } - }) - - if(!teamDetails){ - return reply.status(404).send({error: 'Team not found'}) - } - - const isMember = teamDetails.members.find((m) => paramsUserId === m.user.id) - - if(!isMember){ - return reply.status(404).send({ - error: 'Member not found', - }); - } - - const isOwner = teamDetails.ownerId === userID; - const isSelfRemove = paramsUserId === userID; - - if (!isOwner && !isSelfRemove) { - return reply.status(403).send({ - error: 'Forbidden', - }); - } - - //TODO: Assign owner role to next person - if(paramsUserId === teamDetails.ownerId){ - return reply.status(403).send({ - error: 'Owner cannot leave team', - }); - } - - if(isOwner || isSelfRemove){ - try { - await app.prisma.teamMember.delete({ - where: { - userId_teamId: { - teamId: teamDetails.id, - userId: paramsUserId - } - } - }) - reply.status(200).send('Member removed') - } catch (error) { - app.log.error(error); - - return reply.status(500).send('DB query failed') - } - } - }) - - app.patch('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string},Body: {description?:string, name?:string, avatarUrl?:string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; - const parsed = updateTeam.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - }; - - const {name, description,avatarUrl} = parsed.data; - - - const teamDetails = await app.prisma.team.findUnique({where:{slug: paramsSlug}}) - - if(!teamDetails){ - return reply.status(404).send('Team not found'); - } - - if(teamDetails.ownerId !== userId){ - return reply.status(403).send({ - error: 'Forbidden', - }); - } - - try { - const updatedTeam = await app.prisma.team.update({ - where: { - slug: paramsSlug + user: { + include: { + platformLinks: true, }, - data: { - name, - description, - avatarUrl, - } - }) - return reply.status(200).send(updatedTeam) - } catch (error) { - app.log.error(error); - return reply.status(500).send('DB query failed') - } - - }) - - app.delete('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{Params:{slug: string}}>, reply:FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; - - - const teamDetails = await app.prisma.team.findUnique({ - where:{ - slug: paramsSlug - } - }) - - if(!teamDetails){ - return reply.status(404).send('Team not found'); - } - - if(teamDetails.ownerId !== userId){ - return reply.status(403).send({ - error: 'Forbidden', - }); - } - - try { - await app.prisma.team.delete({ - where: { - slug: paramsSlug, - } - }) - - return reply.status(200).send('Team deleted') - } catch (error) { - app.log.error(error) - - return reply.status(500).send('DB query failed') - } - }) - - app.get('/:slug/qr',async(request:FastifyRequest<{Params:{slug:string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - try { - const teamDetails = await app.prisma.team.findUnique({ - where: { - slug: paramsSlug - } - }) - - if(!teamDetails){ - return reply.status(404).send('Team not found'); - } - - const url = `https://devcard.dev/team/${teamDetails.slug}` - const qrImage = await QRCode.toBuffer(url) - return reply.type('image/png').send(qrImage) - } catch (error) { - app.log.error(error); - return reply.status(500).send("QR generation failed") - } - - }) -} \ No newline at end of file + }, + }, + }, + }, + }); + + if (!details) { + return reply.status(404).send({ error: 'Team not found' }); + } + + const members = details.members.map((tm): TeamMember => ({ + username: tm.user.username, + displayName: tm.user.displayName, + bio: tm.user.bio, + pronouns: tm.user.pronouns, + role: tm.user.role, + company: tm.user.company, + avatarUrl: tm.user.avatarUrl, + accentColor: tm.user.accentColor, + links: tm.user.platformLinks.map((pl: PlatformLink) => ({ + id: pl.id, + platform: pl.platform, + username: pl.username, + url: pl.url, + displayOrder: pl.displayOrder, + })), + teamRole: tm.role, + joinedAt: tm.joinedAt, + })); + + const response: TeamProfile = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + avatarUrl: details.avatarUrl, + ownerId: details.ownerId, + createdAt: details.createdAt, + updatedAt: details.updatedAt, + members, + }; + + return response; + } catch (error) { + app.log.error(error); + return reply.status(500).send('Database query failed'); + } + }); + + app.post('/:slug/members', async (request: FastifyRequest<{ Params: { slug: string }; Body: { username: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const parsed = inviteMembers.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } + const { username } = parsed.data; + try { + const teamDetails = await app.prisma.team.findUnique({ + where: { slug: paramsSlug }, + include: { + owner: true, + members: { + include: { + user: true, + }, + }, + }, + }); + if (!teamDetails) { + return reply.status(404).send('Team not found'); + } + if (teamDetails.ownerId !== userId) { + return reply.status(403).send('Forbidden'); + } + + const alreadyMember = teamDetails.members.find((u) => u.user.username === username); + + if (alreadyMember || teamDetails.owner.username === username) { + return reply.status(409).send('Conflict'); + } + + const invitedUserDetails = await app.prisma.user.findUnique({ where: { username } }); + + if (!invitedUserDetails) { + return reply.status(404).send('User not found'); + } + + await app.prisma.teamMember.create({ + data: { + teamId: teamDetails.id, + userId: invitedUserDetails.id, + role: TeamRole.MEMBER, + joinedAt: new Date(), + }, + }); + + return reply.status(201).send('User invited'); + } catch (error) { + app.log.error(error); + return reply.status(500).send('Database query failed'); + } + }); + + app.delete('/:slug/members/:userId', async (request: FastifyRequest<{ Params: { slug: string; userId: string } }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const paramsSlug = request.params.slug; + const paramsUserId = request.params.userId; + const userID = decoded.id; + const teamDetails = await app.prisma.team.findUnique({ + where: { slug: paramsSlug }, + include: { + members: { + include: { + user: true, + }, + }, + }, + }); + + if (!teamDetails) { + return reply.status(404).send({ error: 'Team not found' }); + } + + const isMember = teamDetails.members.find((m) => paramsUserId === m.user.id); + + if (!isMember) { + return reply.status(404).send({ error: 'Member not found' }); + } + + const isOwner = teamDetails.ownerId === userID; + const isSelfRemove = paramsUserId === userID; + + if (!isOwner && !isSelfRemove) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + if (paramsUserId === teamDetails.ownerId) { + return reply.status(403).send({ error: 'Owner cannot leave team' }); + } + + if (isOwner || isSelfRemove) { + try { + await app.prisma.teamMember.delete({ + where: { + userId_teamId: { + teamId: teamDetails.id, + userId: paramsUserId, + }, + }, + }); + reply.status(200).send('Member removed'); + } catch (error) { + app.log.error(error); + return reply.status(500).send('DB query failed'); + } + } + }); + + app.patch('/:slug', async (request: FastifyRequest<{ Params: { slug: string }; Body: { description?: string; name?: string; avatarUrl?: string } }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const paramsSlug = request.params.slug; + const parsed = updateTeam.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } + + const { name, description, avatarUrl } = parsed.data; + + const teamDetails = await app.prisma.team.findUnique({ where: { slug: paramsSlug } }); + + if (!teamDetails) { + return reply.status(404).send('Team not found'); + } + + if (teamDetails.ownerId !== userId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + try { + const updatedTeam = await app.prisma.team.update({ + where: { slug: paramsSlug }, + data: { name, description, avatarUrl }, + }); + return reply.status(200).send(updatedTeam); + } catch (error) { + app.log.error(error); + return reply.status(500).send('DB query failed'); + } + }); + + app.delete('/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const paramsSlug = request.params.slug; + + const teamDetails = await app.prisma.team.findUnique({ where: { slug: paramsSlug } }); + + if (!teamDetails) { + return reply.status(404).send('Team not found'); + } + + if (teamDetails.ownerId !== userId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + try { + await app.prisma.team.delete({ where: { slug: paramsSlug } }); + return reply.status(200).send('Team deleted'); + } catch (error) { + app.log.error(error); + return reply.status(500).send('DB query failed'); + } + }); + + app.get('/:slug/qr', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + try { + const teamDetails = await app.prisma.team.findUnique({ where: { slug: paramsSlug } }); + + if (!teamDetails) { + return reply.status(404).send('Team not found'); + } + + const url = `https://devcard.dev/team/${teamDetails.slug}`; + const qrImage = await QRCode.toBuffer(url); + return reply.type('image/png').send(qrImage); + } catch (error) { + app.log.error(error); + return reply.status(500).send('QR generation failed'); + } + }); +} diff --git a/apps/backend/src/utils/slug.ts b/apps/backend/src/utils/slug.ts index 24b772f3..3e0db8e3 100644 --- a/apps/backend/src/utils/slug.ts +++ b/apps/backend/src/utils/slug.ts @@ -1,19 +1,27 @@ -export function createSlug(name:string){ - return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]+/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '') +export function createSlug(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]+/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); } -export async function generateUniqueSlug(name: string, - slugExists: (slug: string) => Promise -){ - const cleanSlug = createSlug(name) - let finalSlug = cleanSlug; - while(true){ - const exists = await slugExists(finalSlug) +const MAX_SLUG_ATTEMPTS = 10; - if(!exists) break; +export async function generateUniqueSlug( + name: string, + slugExists: (slug: string) => Promise, +): Promise { + const baseSlug = createSlug(name); - const randomSuffix = Math.random().toString(36).substring(2,6); - finalSlug = `${cleanSlug}-${randomSuffix}` - } - return finalSlug; + if (!(await slugExists(baseSlug))) { return baseSlug; } + + for (let i = 1; i <= MAX_SLUG_ATTEMPTS; i++) { + const candidate = `${baseSlug}-${i}`; + if (!(await slugExists(candidate))) { return candidate; } + } + + throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS} attempts`); } From c9c0b861ef7bc81946f36d0bcd049b4dbe2a7221 Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Thu, 18 Jun 2026 21:01:57 +0530 Subject: [PATCH 2/3] fix(events): apply P2002 retry strategy and fix concurrency safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same bounded retry loop (5 attempts) used for team slug allocation to event creation, so P2002 unique-constraint violations from concurrent inserts trigger re-allocation rather than surfacing as 500 errors. Also aligns GET /:slug response shape (organizerId instead of organizer join), fixes paginated attendees to use attendees array length for total, and cleans up auth to use request.jwtVerify() inline — consistent with the team routes approach. Co-Authored-By: Ridanshi --- apps/backend/src/routes/event.ts | 460 +++++++++++++++---------------- 1 file changed, 225 insertions(+), 235 deletions(-) diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 8d7bc566..45390784 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,21 +1,21 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation.js'; +import { Prisma } from '@prisma/client'; -import {generateUniqueSlug} from '../utils/slug.js' +import { generateUniqueSlug } from '../utils/slug.js'; +import { createEventSchema } from '../validations/event.validation.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; type EventDetails = { - id: string; - name: string; - slug: string; - location: string; - description: string | null; - organizerUsername: string; - organizerDisplayName: string; - startDate: Date; - endDate: Date; - createdAt: Date; - attendeesCount: number + id: string; + name: string; + slug: string; + location: string; + description: string | null; + organizerId: string; + startDate: Date; + endDate: Date; + createdAt: Date; + attendeesCount: number; } type AttendeePublicProfile = { @@ -29,257 +29,247 @@ type AttendeePublicProfile = { accentColor: string; } - type PaginatedAttendeesResponse = { attendees: AttendeePublicProfile[]; pagination: { page: number; limit: number; - total: number; + total: number; }; } -type EventWithAttendees = { - _count: { - attendees: number; +type AttendeeRow = { + user: { + id: string; + username: string; + displayName: string; + bio: string | null; + pronouns: string | null; + company: string | null; + avatarUrl: string | null; + accentColor: string; }; - attendees: { - user: { - id: string; - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - }[]; } -export async function eventRoutes(app:FastifyInstance) { - app.post('/', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async (request: FastifyRequest<{ - Body: { - name: string, - description?: string, - startDate: string, - location: string, - endDate: string, - isPublic?: boolean - }}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const parsed = createEventSchema.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - } - - const {name, description, startDate, endDate, isPublic ,location} = parsed.data +export async function eventRoutes(app: FastifyInstance): Promise { + app.post('/', async (request: FastifyRequest<{ + Body: { + name: string; + description?: string; + startDate: string; + location: string; + endDate: string; + isPublic?: boolean; + }; + }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const parsed = createEventSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) - - return !!existing - }) + const { name, description, startDate, endDate, isPublic, location } = parsed.data; + const startDateObj = new Date(startDate); + const endDateObj = new Date(endDate); - const startDateObj = new Date(startDate); - const endDateObj = new Date(endDate); + const MAX_CREATE_ATTEMPTS = 5; - try { - const newEvent = await app.prisma.event.create({ - data: { - name, - description, - slug: finalSlug, - location: location, - startDate: startDateObj, - endDate: endDateObj, - isPublic: isPublic ?? true, - organizerId: userId - } - }) + for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt++) { + let finalSlug: string; + try { + finalSlug = await generateUniqueSlug(name, async (slug) => { + const existing = await app.prisma.event.findUnique({ where: { slug } }); + return !!existing; + }); + } catch { + return reply.status(409).send({ error: 'Unable to generate a unique event slug' }); + } - return reply.status(201).send(newEvent); - } catch (error) { - app.log.error('Failed to create event'); - return reply.status(500).send({error: 'Failed to create event'}) + try { + const newEvent = await app.prisma.event.create({ + data: { + name, + description, + slug: finalSlug, + location, + startDate: startDateObj, + endDate: endDateObj, + isPublic: isPublic ?? true, + organizerId: userId, + }, + }); + return reply.status(201).send(newEvent); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + continue; } - - }) + app.log.error('Failed to create event'); + return reply.status(500).send({ error: 'Failed to create event' }); + } + } - //Returns event details and attendees count - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const details = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug, - }, - include: { - _count: { - select: { - attendees: true - } - }, - organizer: { - select: { - username: true, - displayName: true - } - } - } - }) - if(!details){ - return reply.status(404).send({error: 'Event not found'}) - } + return reply.status(409).send({ error: 'Unable to allocate a unique event slug' }); + }); - const response: EventDetails = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - location: details.location, - organizerUsername: details.organizer.username, - organizerDisplayName: details.organizer.displayName, - startDate: details.startDate, - endDate: details.endDate, - createdAt: details.createdAt, - attendeesCount: details._count.attendees - } - - return response; - }) + app.get('/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const details = await app.prisma.event.findUnique({ + where: { slug: paramsSlug }, + include: { + _count: { + select: { attendees: true }, + }, + }, + }); + if (!details) { + return reply.status(404).send({ error: 'Event not found' }); + } - app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; + const response: EventDetails = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + location: details.location, + organizerId: details.organizerId, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees, + }; - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) + return response; + }); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + app.post('/:slug/join', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const paramsSlug = request.params.slug; - try { - await app.prisma.eventAttendee.create({ - data: { - eventId: event.id, - userId: userId, - joinedAt: new Date() - } - }) + const event = await app.prisma.event.findUnique({ where: { slug: paramsSlug } }); - return reply.status(201).send({message: 'User joined successfully'}) - } catch (error:any) { - if(error.code === "P2002" ){ - return reply.status(409).send({error: 'Already joined'}) - } - app.log.error((error as Error).message); - return reply.status(500).send({error: 'Failed to join'}) - } + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - }) + try { + await app.prisma.eventAttendee.create({ + data: { + eventId: event.id, + userId, + joinedAt: new Date(), + }, + }); + return reply.status(201).send({ message: 'User joined successfully' }); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(409).send({ error: 'Already joined' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to join' }); + } + }); - app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const userId = (request.user as any).id; - const paramsSlug = request.params.slug; + app.delete('/:slug/leave', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + let decoded; + try { + decoded = await request.jwtVerify() as any; + } catch (_error) { + return reply.status(401).send({ error: 'Unauthorized' }); + } + const userId = decoded.id; + const paramsSlug = request.params.slug; - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) + const event = await app.prisma.event.findUnique({ where: { slug: paramsSlug } }); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - try { - await app.prisma.eventAttendee.delete({ - where: { - userId_eventId: { - userId: userId, - eventId: event.id - } - } - }) - return reply.status(204).send({message: 'User left'}) - } catch (error:any) { - if(error.code === 'P2025'){ - return reply.status(404).send({error: 'User not found'}) - } - app.log.error((error as Error).message) - return reply.status(500).send({error: 'Failed to leave'}) - } - }) + try { + await app.prisma.eventAttendee.delete({ + where: { + userId_eventId: { + userId, + eventId: event.id, + }, + }, + }); + return reply.status(204).send({ message: 'User left' }); + } catch (error: any) { + if (error.code === 'P2025') { + return reply.status(404).send({ error: 'User not found' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to leave' }); + } + }); - app.get('/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const page = Math.max(1, Number(request.query.page) || 1); - const limit = Math.min(50, Number(request.query.limit) || 10); - const skip = (page - 1) * limit - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - }, - include: { - _count: { - select: { attendees: true } - }, - attendees : { - include: { - user: { - select: { - id: true, - username: true, - displayName:true, - bio: true, - pronouns: true, - company: true, - avatarUrl: true, - accentColor: true - } - } - }, - skip, - take: limit, - orderBy: {joinedAt: 'desc'} - } - }, - })as EventWithAttendees | null; + app.get('/:slug/attendees', async (request: FastifyRequest<{ Params: { slug: string }; Querystring: { page?: string; limit?: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(50, Number(request.query.limit) || 10); + const skip = (page - 1) * limit; + const event = await app.prisma.event.findUnique({ + where: { slug: paramsSlug }, + include: { + attendees: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + company: true, + avatarUrl: true, + accentColor: true, + }, + }, + }, + skip, + take: limit, + orderBy: { joinedAt: 'desc' }, + }, + }, + }); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - - const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ - id: attendee.user.id, - username: attendee.user.username, - displayName: attendee.user.displayName, - bio: attendee.user.bio, - pronouns: attendee.user.pronouns, - company: attendee.user.company, - avatarUrl: attendee.user.avatarUrl, - accentColor: attendee.user.accentColor, - })); + const attendees = (event as any).attendees.map((attendee: AttendeeRow) => ({ + id: attendee.user.id, + username: attendee.user.username, + displayName: attendee.user.displayName, + bio: attendee.user.bio, + pronouns: attendee.user.pronouns, + company: attendee.user.company, + avatarUrl: attendee.user.avatarUrl, + accentColor: attendee.user.accentColor, + })); - const response: PaginatedAttendeesResponse = { - attendees, - pagination: { - page, - limit, - total : event._count.attendees, - } - } + const response: PaginatedAttendeesResponse = { + attendees, + pagination: { + page, + limit, + total: (event as any).attendees.length, + }, + }; - return response; - }) -} \ No newline at end of file + return response; + }); +} From 3394b060c94ca963e4f2ea1f071afe06724f158f Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Thu, 18 Jun 2026 21:13:55 +0530 Subject: [PATCH 3/3] fix(teams): resolve import extensions and inline TeamMember type Update imports to use .js extensions for ESM compatibility with the project's module resolution convention. Inline TeamMember type fields to avoid the intersection with PublicProfile which does not resolve cleanly without a built shared package. --- apps/backend/src/routes/team.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index d7abbfed..82bebdec 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -4,10 +4,19 @@ import QRCode from 'qrcode'; import { generateUniqueSlug } from '../utils/slug.js'; import { createTeamScehma, inviteMembers, updateTeam } from '../validations/team.validation.js'; -import type { PlatformLink, PublicProfile } from '@devcard/shared'; +import type { PlatformLink } from '@devcard/shared'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -type TeamMember = PublicProfile & { +type TeamMember = { + username: string; + displayName: string; + bio: string | null; + pronouns: string | null; + role: string | null; + company: string | null; + avatarUrl: string | null; + accentColor: string; + links: PlatformLink[]; teamRole: TeamRole; joinedAt: Date; }