From 5adeaee77507261f8b0b4152cd5f6af4be0185bd Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 24 May 2026 16:31:38 +0530 Subject: [PATCH 1/3] chore: resolve merge conflicts and sync with upstream --- apps/backend/prisma/schema.prisma | 42 +- apps/backend/src/__tests__/team.test.ts | 776 ++++++++++++++++++ apps/backend/src/app.ts | 2 +- apps/backend/src/routes/event.ts | 56 +- apps/backend/src/routes/team.ts | 410 +++++++++ apps/backend/src/utils/slug.ts | 19 + .../src/validations/team.validation.ts | 26 + 7 files changed, 1297 insertions(+), 34 deletions(-) create mode 100644 apps/backend/src/__tests__/team.test.ts create mode 100644 apps/backend/src/routes/team.ts create mode 100644 apps/backend/src/utils/slug.ts create mode 100644 apps/backend/src/validations/team.validation.ts diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f2d3afe0..7017ca81 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -30,8 +30,10 @@ model User { viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] organizer Event[] - attendedEvents EventAttendee[] + attendedEvents EventAttendee[] + ownedTeams Team[] @relation("TeamOwner") + teamMemberships TeamMember[] @relation("TeamMember") @@unique([provider, providerId]) @@map("users") @@ -154,4 +156,42 @@ model EventAttendee { user User @relation(fields: [userId],references: [id]) @@unique([userId, eventId]) +} + +enum TeamRole { + OWNER + ADMIN + MEMBER +} + +model Team{ + id String @id @default(uuid()) + name String + slug String @unique + description String? + avatarUrl String? + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) + members TeamMember[] @relation("TeamMember") + + @@map("teams") + @@index([slug]) +} + +model TeamMember{ + id String @id @default(uuid()) + teamId String + userId String + role TeamRole + joinedAt DateTime + + team Team @relation("TeamMember",fields: [teamId] , references: [id]) + user User @relation("TeamMember",fields: [userId] , references: [id]) + + @@unique([userId, teamId]) + @@index([userId]) + @@map("team_members") } \ No newline at end of file diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts new file mode 100644 index 00000000..350298a1 --- /dev/null +++ b/apps/backend/src/__tests__/team.test.ts @@ -0,0 +1,776 @@ +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'; + +// ─── Shared mock data ───────────────────────────────────────────────────────── + +const MOCK_OWNER_ID = 'user-uuid-001'; +const MOCK_MEMBER_ID = 'user-uuid-002'; +const MOCK_OUTSIDER_ID = 'user-uuid-003'; + +const MOCK_OWNER = { + id: MOCK_OWNER_ID, + username: 'johndoe', + displayName: 'John Doe', + bio: 'Team owner', + pronouns: 'he/him', + role: 'Software Engineer', + company: 'Acme Corp', + avatarUrl: 'https://example.com/john.png', + accentColor: '#6366f1', +}; + +const MOCK_MEMBER_USER = { + id: MOCK_MEMBER_ID, + username: 'janedoe', + displayName: 'Jane Doe', + bio: null, + pronouns: null, + role: 'Designer', + company: null, + avatarUrl: null, + accentColor: '#f43f5e', +}; + +const MOCK_PLATFORM_LINKS = [ + { id: 'link-uuid-001', platform: 'github', username: 'johndoe', url: 'https://github.com/johndoe', displayOrder: 0 }, + { id: 'link-uuid-002', platform: 'twitter', username: 'johndoe_', url: 'https://twitter.com/johndoe_', displayOrder: 1 }, +]; + +const MOCK_TEAM = { + id: 'team-uuid-001', + name: 'DevCard Core', + slug: 'devcard-core', + description: 'Building the future of developer cards', + avatarUrl: 'https://example.com/team.png', + ownerId: MOCK_OWNER_ID, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-06-01T00:00:00Z'), +}; + +const MOCK_TEAM_WITH_MEMBERS = { + ...MOCK_TEAM, + members: [ + { + id: 'tm-uuid-001', + teamId: MOCK_TEAM.id, + userId: MOCK_OWNER_ID, + role: TeamRole.OWNER, + joinedAt: new Date('2024-01-01T00:00:00Z'), + user: { ...MOCK_OWNER, platformLinks: MOCK_PLATFORM_LINKS }, + }, + { + id: 'tm-uuid-002', + teamId: MOCK_TEAM.id, + userId: MOCK_MEMBER_ID, + role: TeamRole.MEMBER, + joinedAt: new Date('2024-02-01T00:00:00Z'), + user: { ...MOCK_MEMBER_USER, platformLinks: [] }, + }, + ], +}; + +// ─── Prisma mock ────────────────────────────────────────────────────────────── + +const prismaMock = { + team: { + create: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + teamMember: { + create: vi.fn(), + delete: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + $transaction: vi.fn(), +}; + +// ─── App factory ────────────────────────────────────────────────────────────── + +let mockJwtVerify = vi.fn(); + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + app.decorate('prisma', prismaMock as unknown as PrismaClient); + + app.decorateRequest('jwtVerify', function () { + return mockJwtVerify(); + }); + + await app.register(teamRoutes); + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function authHeader(): Record { + return { Authorization: 'Bearer mock-token' }; +} + +async function createTeam( + app: FastifyInstance, + body: Record, + authenticated = true, +) { + return app.inject({ + method: 'POST', + url: '/', + headers: authenticated ? authHeader() : {}, + payload: body, + }); +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe('Teams API', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + mockJwtVerify.mockResolvedValue({ id: MOCK_OWNER_ID }); + app = await buildApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + // ── POST / — create team ────────────────────────────────────────────────── + + describe('POST / — create team', () => { + const validBody = { + name: 'DevCard Core', + description: 'Building the future of developer cards', + avatarUrl: 'https://example.com/team.png', + }; + + it('201 — creates team and auto-adds owner as OWNER member', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + prismaMock.$transaction.mockImplementation(async (cb: any) => { + return cb({ + team: { create: vi.fn().mockResolvedValue(MOCK_TEAM) }, + teamMember: { create: vi.fn().mockResolvedValue({}) }, + }); + }); + + const res = await createTeam(app, validBody); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.name).toBe('DevCard Core'); + expect(body.ownerId).toBe(MOCK_OWNER_ID); + expect(body.slug).toBe('devcard-core'); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await createTeam(app, validBody, false); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: 'Unauthorized' }); + }); + + it('400 — rejects name shorter than 3 characters', async () => { + const res = await createTeam(app, { ...validBody, name: 'AB' }); + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects name longer than 100 characters', async () => { + const res = await createTeam(app, { ...validBody, name: 'A'.repeat(101) }); + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects invalid avatarUrl', async () => { + const res = await createTeam(app, { ...validBody, avatarUrl: 'not-a-url' }); + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects missing name', async () => { + const { name: _omit, ...bodyWithoutName } = validBody; + const res = await createTeam(app, bodyWithoutName); + expect(res.statusCode).toBe(400); + }); + + it('201 — creates team without optional fields', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + prismaMock.$transaction.mockImplementation(async (cb: any) => { + return cb({ + team: { create: vi.fn().mockResolvedValue({ ...MOCK_TEAM, description: null, avatarUrl: null }) }, + teamMember: { create: vi.fn().mockResolvedValue({}) }, + }); + }); + + const res = await createTeam(app, { name: 'DevCard Core' }); + expect(res.statusCode).toBe(201); + }); + + it('500 — returns 500 on database failure', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + prismaMock.$transaction.mockRejectedValue(new Error('DB error')); + + const res = await createTeam(app, validBody); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ error: 'Failed to create team' }); + }); + }); + + // ── GET /:slug — public team profile ───────────────────────────────────── + + describe('GET /:slug — public team profile', () => { + it('200 — returns team with members in PublicProfile shape', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM_WITH_MEMBERS); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + + expect(body.slug).toBe('devcard-core'); + expect(body.ownerId).toBe(MOCK_OWNER_ID); + expect(body.members).toHaveLength(2); + }); + + it('200 — each member has PublicProfile fields and links array', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM_WITH_MEMBERS); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + const owner = res.json().members[0]; + + expect(owner).toHaveProperty('username', 'johndoe'); + expect(owner).toHaveProperty('displayName', 'John Doe'); + expect(owner).toHaveProperty('accentColor'); + expect(owner).toHaveProperty('links'); + expect(owner.links).toHaveLength(2); + expect(owner.links[0]).toMatchObject({ + platform: 'github', + username: 'johndoe', + url: 'https://github.com/johndoe', + displayOrder: 0, + }); + }); + + it('200 — member has teamRole and joinedAt fields', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM_WITH_MEMBERS); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + const owner = res.json().members[0]; + + expect(owner).toHaveProperty('teamRole', 'OWNER'); + expect(owner).toHaveProperty('joinedAt'); + }); + + it('200 — does not leak sensitive user fields on members', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM_WITH_MEMBERS); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + const member = res.json().members[0]; + + expect(member).not.toHaveProperty('email'); + expect(member).not.toHaveProperty('provider'); + expect(member).not.toHaveProperty('providerId'); + }); + + it('200 — works without authentication (public endpoint)', async () => { + mockJwtVerify.mockRejectedValue(new Error('Should not be called')); + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM_WITH_MEMBERS); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + + it('404 — returns 404 for unknown slug', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ method: 'GET', url: '/ghost-team' }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: 'Team not found' }); + }); + + it('200 — returns empty members array for a team with no members', async () => { + prismaMock.team.findUnique.mockResolvedValue({ ...MOCK_TEAM, members: [] }); + + const res = await app.inject({ method: 'GET', url: '/devcard-core' }); + + expect(res.statusCode).toBe(200); + expect(res.json().members).toHaveLength(0); + }); + }); + + // ── POST /:slug/members — invite member ─────────────────────────────────── + + describe('POST /:slug/members — invite member (owner only)', () => { + const teamWithOwnerOnly = { + ...MOCK_TEAM, + owner: MOCK_OWNER, + members: [ + { + id: 'tm-uuid-001', + teamId: MOCK_TEAM.id, + userId: MOCK_OWNER_ID, + role: TeamRole.OWNER, + joinedAt: new Date(), + user: MOCK_OWNER, + }, + ], + }; + + it('201 — owner can invite a new member by username', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithOwnerOnly); + prismaMock.user.findUnique.mockResolvedValue(MOCK_MEMBER_USER); + prismaMock.teamMember.create.mockResolvedValue({}); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: 'janedoe' }, + }); + + expect(res.statusCode).toBe(201); + expect(prismaMock.teamMember.create).toHaveBeenCalledOnce(); + + const callData = prismaMock.teamMember.create.mock.calls[0][0].data; + expect(callData.userId).toBe(MOCK_MEMBER_ID); + expect(callData.role).toBe(TeamRole.MEMBER); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + payload: { username: 'janedoe' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('403 — non-owner cannot invite members', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_MEMBER_ID }); + prismaMock.team.findUnique.mockResolvedValue(teamWithOwnerOnly); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: 'someoneelse' }, + }); + + expect(res.statusCode).toBe(403); + expect(prismaMock.teamMember.create).not.toHaveBeenCalled(); + }); + + it('409 — cannot invite a user who is already a member', async () => { + prismaMock.team.findUnique.mockResolvedValue({ + ...teamWithOwnerOnly, + members: [ + ...teamWithOwnerOnly.members, + { + id: 'tm-uuid-002', + teamId: MOCK_TEAM.id, + userId: MOCK_MEMBER_ID, + role: TeamRole.MEMBER, + joinedAt: new Date(), + user: MOCK_MEMBER_USER, + }, + ], + }); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: 'janedoe' }, + }); + + expect(res.statusCode).toBe(409); + expect(prismaMock.teamMember.create).not.toHaveBeenCalled(); + }); + + it('409 — cannot invite the owner (they are already a member)', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithOwnerOnly); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: 'johndoe' }, + }); + + expect(res.statusCode).toBe(409); + }); + + it('404 — returns 404 when invited username does not exist', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithOwnerOnly); + prismaMock.user.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: 'ghostuser' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('404 — returns 404 when team does not exist', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'POST', + url: '/ghost-team/members', + headers: authHeader(), + payload: { username: 'janedoe' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('400 — rejects empty username', async () => { + const res = await app.inject({ + method: 'POST', + url: '/devcard-core/members', + headers: authHeader(), + payload: { username: '' }, + }); + + expect(res.statusCode).toBe(400); + }); + }); + + // ── DELETE /:slug/members/:userId — remove member ───────────────────────── + + describe('DELETE /:slug/members/:userId — remove member', () => { + const teamWithBothMembers = { + ...MOCK_TEAM, + members: [ + { + id: 'tm-uuid-001', + teamId: MOCK_TEAM.id, + userId: MOCK_OWNER_ID, + role: TeamRole.OWNER, + joinedAt: new Date(), + user: MOCK_OWNER, + }, + { + id: 'tm-uuid-002', + teamId: MOCK_TEAM.id, + userId: MOCK_MEMBER_ID, + role: TeamRole.MEMBER, + joinedAt: new Date(), + user: MOCK_MEMBER_USER, + }, + ], + }; + + it('200 — owner can remove a member', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithBothMembers); + prismaMock.teamMember.delete.mockResolvedValue({}); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_MEMBER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const deleteArg = prismaMock.teamMember.delete.mock.calls[0][0].where; + expect(deleteArg).toMatchObject({ + userId_teamId: { + teamId: MOCK_TEAM.id, + userId: MOCK_MEMBER_ID, + }, + }); + }); + + it('200 — member can self-remove (leave team)', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_MEMBER_ID }); + prismaMock.team.findUnique.mockResolvedValue(teamWithBothMembers); + prismaMock.teamMember.delete.mockResolvedValue({}); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_MEMBER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + }); + + it('403 — owner cannot leave their own team', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithBothMembers); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_OWNER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(prismaMock.teamMember.delete).not.toHaveBeenCalled(); + }); + + it('403 — outsider cannot remove another member', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_OUTSIDER_ID }); + prismaMock.team.findUnique.mockResolvedValue(teamWithBothMembers); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_MEMBER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(prismaMock.teamMember.delete).not.toHaveBeenCalled(); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_MEMBER_ID}`, + }); + + expect(res.statusCode).toBe(401); + }); + + it('404 — returns 404 when team does not exist', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'DELETE', + url: `/ghost-team/members/${MOCK_MEMBER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + }); + + it('404 — returns 404 when userId is not a team member', async () => { + prismaMock.team.findUnique.mockResolvedValue(teamWithBothMembers); + + const res = await app.inject({ + method: 'DELETE', + url: `/devcard-core/members/${MOCK_OUTSIDER_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + }); + }); + + // ── PATCH /:slug — update team ──────────────────────────────────────────── + + describe('PATCH /:slug — update team (owner only)', () => { + it('200 — owner can update name, description, avatarUrl', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + prismaMock.team.update.mockResolvedValue({ ...MOCK_TEAM, name: 'New Name' }); + + const res = await app.inject({ + method: 'PATCH', + url: '/devcard-core', + headers: authHeader(), + payload: { name: 'New Name' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().name).toBe('New Name'); + }); + + it('403 — non-owner cannot update team', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_MEMBER_ID }); + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + + const res = await app.inject({ + method: 'PATCH', + url: '/devcard-core', + headers: authHeader(), + payload: { name: 'Hijacked Name' }, + }); + + expect(res.statusCode).toBe(403); + expect(prismaMock.team.update).not.toHaveBeenCalled(); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'PATCH', + url: '/devcard-core', + payload: { name: 'New Name' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('400 — rejects empty body (at least one field required)', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/devcard-core', + headers: authHeader(), + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + it('400 — rejects invalid avatarUrl', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/devcard-core', + headers: authHeader(), + payload: { avatarUrl: 'not-a-url' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('404 — returns 404 for unknown slug', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'PATCH', + url: '/ghost-team', + headers: authHeader(), + payload: { name: 'New Name' }, + }); + + expect(res.statusCode).toBe(404); + }); + }); + + // ── DELETE /:slug — delete team ─────────────────────────────────────────── + + describe('DELETE /:slug — delete team (owner only)', () => { + it('200 — owner can delete team', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + prismaMock.team.delete.mockResolvedValue({}); + + const res = await app.inject({ + method: 'DELETE', + url: '/devcard-core', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + expect(prismaMock.team.delete).toHaveBeenCalledOnce(); + }); + + it('403 — non-owner cannot delete team', async () => { + mockJwtVerify.mockResolvedValue({ id: MOCK_MEMBER_ID }); + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + + const res = await app.inject({ + method: 'DELETE', + url: '/devcard-core', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(403); + expect(prismaMock.team.delete).not.toHaveBeenCalled(); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue(new Error('Unauthorized')); + + const res = await app.inject({ + method: 'DELETE', + url: '/devcard-core', + }); + + expect(res.statusCode).toBe(401); + }); + + it('404 — returns 404 for unknown slug', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'DELETE', + url: '/ghost-team', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + }); + + it('500 — returns 500 on database failure', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + prismaMock.team.delete.mockRejectedValue(new Error('DB error')); + + const res = await app.inject({ + method: 'DELETE', + url: '/devcard-core', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /:slug/qr — QR code ─────────────────────────────────────────────── + + describe('GET /:slug/qr — QR code', () => { + it('200 — returns PNG image for valid slug', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + + const res = await app.inject({ + method: 'GET', + url: '/devcard-core/qr', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toMatch('image/png'); + }); + + it('200 — encodes correct devcard.dev URL in QR', async () => { + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + + const res = await app.inject({ + method: 'GET', + url: '/devcard-core/qr', + }); + + expect(res.statusCode).toBe(200); + expect(res.rawPayload.length).toBeGreaterThan(0); + }); + + it('200 — works without authentication (public endpoint)', async () => { + mockJwtVerify.mockRejectedValue(new Error('Should not be called')); + prismaMock.team.findUnique.mockResolvedValue(MOCK_TEAM); + + const res = await app.inject({ + method: 'GET', + url: '/devcard-core/qr', + }); + + expect(res.statusCode).toBe(200); + expect(mockJwtVerify).not.toHaveBeenCalled(); + }); + + it('404 — returns 404 for unknown slug', async () => { + prismaMock.team.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/ghost-team/qr', + }); + + expect(res.statusCode).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 6a937a88..d65aad36 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -106,7 +106,7 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); -await app.register(nfcRoutes, { prefix: '/api/nfc' }); + await app.register(nfcRoutes, { prefix: '/api/nfc' }); await app.register(eventRoutes, { prefix: '/api/events' }); // ─── Health Check ─── type HealthResponse = { diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 9dbe9299..b566874f 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,6 +1,8 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { createEventSchema, joinEventSchema} from '../validations/event.validation'; -import { Prisma } from '@prisma/client'; + +import {generateUniqueSlug} from '../utils/slug' + type EventDetails = { id: string; @@ -36,26 +38,23 @@ type PaginatedAttendeesResponse = { }; } -type EventWithAttendees = Prisma.EventGetPayload<{ - include: { - attendees: { - include: { - user: { - select: { - id: true; - username: true; - displayName: true; - bio: true; - pronouns: true; - company: true; - avatarUrl: true; - accentColor: true; - }; - }; - }; - }; +type EventWithAttendees = { + _count: { + attendees: number; }; -}>; + 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('/' , async(request: FastifyRequest<{ @@ -81,18 +80,11 @@ export async function eventRoutes(app:FastifyInstance) { const {name, description, startDate, endDate, isPublic ,location} = parsed.data - let cleanSlug = name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]+/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, '') - let finalSlug = cleanSlug; - - while(true){ - const existing = await app.prisma.event.findUnique({where: {slug : finalSlug}}); - - if(!existing){ - break; - } - const randomSuffix = Math.random().toString(36).substring(2,6); - finalSlug = `${cleanSlug}-${randomSuffix}` - } + let finalSlug = await generateUniqueSlug(name, async(slug) => { + const existing = await app.prisma.event.findUnique({where: {slug : slug}}) + + return !!existing + }) const startDateObj = new Date(startDate); const endDateObj = new Date(endDate); diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts new file mode 100644 index 00000000..d8656452 --- /dev/null +++ b/apps/backend/src/routes/team.ts @@ -0,0 +1,410 @@ +import {Prisma, TeamRole } from '@prisma/client'; +import QRCode from 'qrcode' + +import {generateUniqueSlug} from '../utils/slug' +import { createTeamScehma,inviteMembers,updateTeam } from '../validations/team.validation'; + +import type {PlatformLink, PublicProfile} from '@devcard/shared' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +type TeamMember = PublicProfile & { + teamRole: TeamRole + joinedAt: Date; +} + +type TeamProfile = { + id: string; + name: string; + slug: string; + description: string | null; + ownerId: string; + avatarUrl: string | null; + createdAt: Date; + updatedAt: Date | null; + members: TeamMember[]; +} + +export async function teamRoutes(app:FastifyInstance){ + 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 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' + }); + } + }) + + 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', 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'); + } + //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', 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', + }); + } + + //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',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") + } + + }) +} \ No newline at end of file diff --git a/apps/backend/src/utils/slug.ts b/apps/backend/src/utils/slug.ts new file mode 100644 index 00000000..24b772f3 --- /dev/null +++ b/apps/backend/src/utils/slug.ts @@ -0,0 +1,19 @@ +export function createSlug(name: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) + + if(!exists) break; + + const randomSuffix = Math.random().toString(36).substring(2,6); + finalSlug = `${cleanSlug}-${randomSuffix}` + } + return finalSlug; +} diff --git a/apps/backend/src/validations/team.validation.ts b/apps/backend/src/validations/team.validation.ts new file mode 100644 index 00000000..153333c0 --- /dev/null +++ b/apps/backend/src/validations/team.validation.ts @@ -0,0 +1,26 @@ +import {z} from 'zod'; + +export const createTeamScehma = z.object({ + name: z.string().min(3, 'Event name must be at least 3 characters long').max(100,'Event name cannot be longer than 100 characters'), + description: z.string().min(1).optional(), + avatarUrl : z.string().url().optional(), +}) + + +export const inviteMembers = z.object({ + username: z.string().min(1,'Username must be atleast 1 character') +}) + +export const updateTeam = z.object({ + name: z.string().min(1, 'Name must be at least 1 character').optional(), + description: z.string().min(1,'Description must be at least 1 character').optional(), + avatarUrl: z.string().url('Invalid avatar URL').optional(), +}).refine( + (data) => + data.name !== undefined || + data.description !== undefined || + data.avatarUrl !== undefined, + { + message: 'At least one field is required', + } +) \ No newline at end of file From 51d599c07a56d7c2da9b92feb9a6a387ec58e03b Mon Sep 17 00:00:00 2001 From: Harxhit Date: Sun, 24 May 2026 16:12:36 +0530 Subject: [PATCH 2/3] feat: Added app.ts --- apps/backend/src/app.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index d65aad36..568f1deb 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -20,7 +20,11 @@ import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; import { nfcRoutes } from './routes/nfc.js'; import { eventRoutes } from './routes/event.js'; +<<<<<<< HEAD import { validateEnv } from './utils/validateEnv.js'; +======= +import { teamRoutes } from './routes/team.js'; +>>>>>>> fa50702 (feat: Added app.ts) const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -106,8 +110,15 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); +<<<<<<< HEAD await app.register(nfcRoutes, { prefix: '/api/nfc' }); await app.register(eventRoutes, { prefix: '/api/events' }); +======= + await app.register(eventRoutes, {prefix: '/api/events'}) + await app.register(teamRoutes, {prefix: '/api/teams'}) + + +>>>>>>> fa50702 (feat: Added app.ts) // ─── Health Check ─── type HealthResponse = { status: 'ok'; From c2b3df62d90d240a013a9ddfd721d28a6f9d82ec Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Fri, 12 Jun 2026 19:12:14 +0530 Subject: [PATCH 3/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 | 86 +++++++++++-------------- apps/backend/src/utils/slug.ts | 36 +++++++---- 4 files changed, 182 insertions(+), 66 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 d8656452..7871b11b 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -24,14 +24,14 @@ type TeamProfile = { members: TeamMember[]; } -export async function teamRoutes(app:FastifyInstance){ +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) { + } catch (_error) { return reply.status(401).send({error : 'Unauthorized'}) } @@ -42,55 +42,45 @@ export async function teamRoutes(app:FastifyInstance){ }; const {name , description , avatarUrl} = parsed.data; - const finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.team.findUnique({where: {slug }}) + const MAX_CREATE_ATTEMPTS = 5; - return !!existing - }) + 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 { + 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) { + 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) { - 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' - }); - } + if (error.code === 'P2002') { + continue; + } + if (error.code === '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' - }); + 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) => { @@ -164,7 +154,7 @@ export async function teamRoutes(app:FastifyInstance){ let decoded; try { decoded = await request.jwtVerify() as any; - } catch (error) { + } catch (_error) { return reply.status(401).send({error : 'Unauthorized'}) } const userId = decoded.id; @@ -231,7 +221,7 @@ export async function teamRoutes(app:FastifyInstance){ let decoded; try { decoded = await request.jwtVerify() as any - } catch (error) { + } catch (_error) { return reply.status(401).send({error : 'Unauthorized'}) } const paramsSlug = request.params.slug @@ -299,7 +289,7 @@ export async function teamRoutes(app:FastifyInstance){ let decoded; try { decoded = await request.jwtVerify() as any - } catch (error) { + } catch (_error) { return reply.status(401).send({error : 'Unauthorized'}) } const userId = decoded.id; @@ -347,7 +337,7 @@ export async function teamRoutes(app:FastifyInstance){ let decoded; try { decoded = await request.jwtVerify() as any - } catch (error) { + } catch (_error) { return reply.status(401).send({error : 'Unauthorized'}) } const userId = decoded.id; 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`); }