diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f2d3afe..7017ca8 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 0000000..350298a --- /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 6a937a8..568f1de 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' }); -await app.register(nfcRoutes, { prefix: '/api/nfc' }); +<<<<<<< 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'; diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 9dbe929..b566874 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 0000000..d865645 --- /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 0000000..24b772f --- /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 0000000..153333c --- /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