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/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..776b4aac 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'; +import { Prisma } from '@prisma/client'; -import {generateUniqueSlug} from '../utils/slug' +import { generateUniqueSlug } from '../utils/slug'; +import { createEventSchema } from '../validations/event.validation'; +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; + }); +} diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index af177e52..262120bc 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' -import { createTeamScehma,inviteMembers,updateTeam } from '../validations/team.validation'; +import { generateUniqueSlug } from '../utils/slug'; +import { createTeamScehma, inviteMembers, updateTeam } from '../validations/team.validation'; -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`); }