From d2ca2536fb1c5c56726ae26e9b14be9fe996382e Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Tue, 19 May 2026 22:09:57 +0530 Subject: [PATCH 1/2] fix(cards): validate platformLink ownership before creating card links POST /api/cards and PUT /api/cards/:id accepted arbitrary platformLink IDs without verifying they belong to the authenticated user. Because platformLink IDs are exposed in the public profile API, any authenticated user could attach another user's verified social links to their own card, enabling impersonation. Add a pre-flight ownership check before each CardLink write. A single indexed query confirms every requested ID exists with userId = current user. If the count does not match, the request is rejected with 403 before any write occurs. Covered by new tests in src/__tests__/cards.test.ts. --- apps/backend/src/__tests__/cards.test.ts | 183 +++++++++++++++++++++++ apps/backend/src/routes/cards.ts | 24 +++ 2 files changed, 207 insertions(+) create mode 100644 apps/backend/src/__tests__/cards.test.ts diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts new file mode 100644 index 0000000..9e7c89d --- /dev/null +++ b/apps/backend/src/__tests__/cards.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import { cardRoutes } from '../routes/cards.js'; + +const USER_ID = 'user-123'; +const CARD_ID = 'card-abc'; +const OWNED_LINK_ID = 'link-owned-1'; +const FOREIGN_LINK_ID = 'link-foreign-x'; + +const mockCard = { + id: CARD_ID, + userId: USER_ID, + title: 'My Card', + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + cardLinks: [], +}; + +const mockPrisma = { + card: { + count: vi.fn(), + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + cardLink: { + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + platformLink: { + findMany: vi.fn(), + }, +}; + +async function buildApp() { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; + }); + app.register(cardRoutes, { prefix: '/api/cards' }); + await app.ready(); + return app; +} + +describe('POST /api/cards — link ownership validation', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns 403 when a supplied linkId belongs to another user', async () => { + // Ownership check: only 0 of 1 requested IDs are owned + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + // Card must not have been created + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 403 when a mix of owned and foreign linkIds is supplied', async () => { + // Only 1 of 2 requested IDs is owned — count mismatch + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID, FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('creates the card when all linkIds are owned by the user', async () => { + // Ownership check: 1 of 1 requested ID is owned + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.platformLink.findMany).toHaveBeenCalledWith({ + where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, + select: { id: true }, + }); + }); + + it('skips the ownership check and creates the card when linkIds is empty', async () => { + mockPrisma.card.count.mockResolvedValue(1); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Empty Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + // No DB round-trip for ownership when there are no links to check + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('PUT /api/cards/:id — link ownership validation', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns 403 when a supplied linkId belongs to another user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + // Ownership check: 0 of 1 requested IDs are owned + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + // Existing links must not have been deleted + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.createMany).not.toHaveBeenCalled(); + }); + + it('updates links when all supplied linkIds are owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + // Ownership check: 1 of 1 owned + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }); + mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }); + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.platformLink.findMany).toHaveBeenCalledWith({ + where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, + select: { id: true }, + }); + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); + }); + + it('returns 404 when the card does not belong to the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index f1af7b0..bcd6845 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -38,6 +38,18 @@ export async function cardRoutes(app: FastifyInstance) { return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); } + // Verify every supplied link belongs to the authenticated user + if (parsed.data.linkIds.length > 0) { + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== parsed.data.linkIds.length) { + return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + } + } + // Check if user's first card → make it default const cardCount = await app.prisma.card.count({ where: { userId } }); @@ -98,6 +110,18 @@ export async function cardRoutes(app: FastifyInstance) { // Update card links if provided if (parsed.data.linkIds) { + // Verify every supplied link belongs to the authenticated user + if (parsed.data.linkIds.length > 0) { + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== parsed.data.linkIds.length) { + return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + } + } + // Remove existing links await app.prisma.cardLink.deleteMany({ where: { cardId: id } }); // Add new links From 8da2ea1b56bf29cd9510e5c4a77b4d7214f47b7f Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Wed, 20 May 2026 17:57:19 +0530 Subject: [PATCH 2/2] refactor(cards): add try/catch and for DB-side reliability Following reviewer feedback on the IDOR ownership-validation PR: - Wrap all five route handlers in try/catch so unexpected DB exceptions always return { error: 'Internal server error' } rather than leaking a raw 500 from Fastify's default error handler. - Replace the bare deleteMany + createMany pair in PUT /cards/:id with a Prisma \ call. Previously a failed createMany after a successful deleteMany left the card with no links (partial write). The transaction rolls both operations back atomically on any failure. - Replace the bare updateMany + update pair in PUT /cards/:id/default with a \ for the same reason: if the second write failed the user had zero default cards until the next successful update. - Add structured app.log.error({ err }) calls in every catch block so DB failures are observable in logs without exposing internals to clients. Tests added (cards.test.ts): - DB error during ownership validation -> 500, no write attempted - DB error during card.count / card.create -> 500 - DB error during card.findFirst in PUT -> 500 - Transaction failure mid-flight (createMany throws) -> 500, card retains existing links - DELETE: card.delete throws -> 500 - PUT /default: transaction failure (update throws after updateMany) -> 500 - PUT /default: success path verifies both ops run inside \ - PUT /:id: success path verifies deleteMany + createMany inside \ --- apps/backend/src/__tests__/cards.test.ts | 251 +++++++++++++++++++- apps/backend/src/routes/cards.ts | 290 ++++++++++++----------- 2 files changed, 397 insertions(+), 144 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 9e7c89d..5bdaedc 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -17,6 +17,8 @@ const mockCard = { cardLinks: [], }; +// $transaction executes the callback synchronously against the same mock client, +// mirroring Prisma's interactive-transactions API without a real DB connection. const mockPrisma = { card: { count: vi.fn(), @@ -35,8 +37,17 @@ const mockPrisma = { platformLink: { findMany: vi.fn(), }, + $transaction: vi.fn(), }; +// Re-wire $transaction before every test so that it executes the callback +// against the same mock client, preserving existing per-operation mocks. +function wireTransaction() { + mockPrisma.$transaction.mockImplementation( + async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), + ); +} + async function buildApp() { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma); @@ -48,11 +59,17 @@ async function buildApp() { return app; } +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/cards +// ───────────────────────────────────────────────────────────────────────────── + describe('POST /api/cards — link ownership validation', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); it('returns 403 when a supplied linkId belongs to another user', async () => { - // Ownership check: only 0 of 1 requested IDs are owned mockPrisma.platformLink.findMany.mockResolvedValue([]); const app = await buildApp(); @@ -64,12 +81,11 @@ describe('POST /api/cards — link ownership validation', () => { expect(res.statusCode).toBe(403); expect(res.json().error).toBe('One or more links do not belong to your account'); - // Card must not have been created expect(mockPrisma.card.create).not.toHaveBeenCalled(); }); it('returns 403 when a mix of owned and foreign linkIds is supplied', async () => { - // Only 1 of 2 requested IDs is owned — count mismatch + // Only 1 of 2 requested IDs is owned — count mismatch triggers 403 mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); const app = await buildApp(); @@ -85,7 +101,6 @@ describe('POST /api/cards — link ownership validation', () => { }); it('creates the card when all linkIds are owned by the user', async () => { - // Ownership check: 1 of 1 requested ID is owned mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); mockPrisma.card.count.mockResolvedValue(0); mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); @@ -116,17 +131,70 @@ describe('POST /api/cards — link ownership validation', () => { }); expect(res.statusCode).toBe(201); - // No DB round-trip for ownership when there are no links to check expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); }); + + it('returns 500 when the ownership query throws unexpectedly', async () => { + mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB connection lost')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + // No write must have been attempted after the read failure + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.count throws and no partial write occurs', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockRejectedValue(new Error('Query timeout')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.create throws', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockRejectedValue(new Error('FK constraint violation')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + }); }); +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id +// ───────────────────────────────────────────────────────────────────────────── + describe('PUT /api/cards/:id — link ownership validation', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); it('returns 403 when a supplied linkId belongs to another user', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - // Ownership check: 0 of 1 requested IDs are owned mockPrisma.platformLink.findMany.mockResolvedValue([]); const app = await buildApp(); @@ -138,14 +206,14 @@ describe('PUT /api/cards/:id — link ownership validation', () => { expect(res.statusCode).toBe(403); expect(res.json().error).toBe('One or more links do not belong to your account'); - // Existing links must not have been deleted + // Existing links must not have been touched + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); expect(mockPrisma.cardLink.createMany).not.toHaveBeenCalled(); }); - it('updates links when all supplied linkIds are owned by the user', async () => { + it('updates links atomically when all supplied linkIds are owned', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - // Ownership check: 1 of 1 owned mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }); mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }); @@ -163,7 +231,9 @@ describe('PUT /api/cards/:id — link ownership validation', () => { where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, select: { id: true }, }); - expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); + // Both operations must run inside the transaction, not as bare queries + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }); expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); }); @@ -180,4 +250,161 @@ describe('PUT /api/cards/:id — link ownership validation', () => { expect(res.statusCode).toBe(404); expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); }); + + it('returns 500 when the ownership query throws and no mutation occurs', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB timeout')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + }); + + it('returns 500 and preserves existing links when the transaction fails mid-flight', async () => { + // Ownership check passes; deleteMany succeeds; createMany fails. + // The transaction rolls back, so the card retains its original links. + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + // Both were attempted inside the transaction (the DB rolls them back together) + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); + // The final read must not have been called — we short-circuited on error + expect(mockPrisma.card.findUnique).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.findFirst throws', async () => { + mockPrisma.card.findFirst.mockRejectedValue(new Error('Connection refused')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DELETE /api/cards/:id +// ───────────────────────────────────────────────────────────────────────────── + +describe('DELETE /api/cards/:id', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 204 on successful deletion', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.delete.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(204); + expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: CARD_ID } }); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.card.delete).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.delete throws', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.delete.mockRejectedValue(new Error('Deadlock detected')); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id/default +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id/default', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 and sets the card as default', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.card.update.mockResolvedValue({ ...mockCard, isDefault: true }); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('Default card updated'); + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + // Clear-all and set-one must both run inside the transaction + expect(mockPrisma.card.updateMany).toHaveBeenCalledWith({ + where: { userId: USER_ID }, + data: { isDefault: false }, + }); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: CARD_ID }, + data: { isDefault: true }, + }); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it('returns 500 and rolls back when the transaction fails mid-flight', async () => { + // updateMany clears all defaults; then update fails → transaction aborts, + // the user retains a consistent default card rather than having none. + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.card.update.mockRejectedValue(new Error('DB write failure')); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.card.updateMany).toHaveBeenCalled(); + expect(mockPrisma.card.update).toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index bcd6845..3c67c5c 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -9,23 +9,28 @@ export async function cardRoutes(app: FastifyInstance) { app.get('/', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; - const cards = await app.prisma.card.findMany({ - where: { userId }, - include: { - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, + try { + const cards = await app.prisma.card.findMany({ + where: { userId }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, }, - }, - orderBy: { createdAt: 'asc' }, - }); - - return cards.map((card) => ({ - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cl) => cl.platformLink), - })); + orderBy: { createdAt: 'asc' }, + }); + + return cards.map((card) => ({ + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cl) => cl.platformLink), + })); + } catch (err) { + app.log.error({ err }, 'DB error in GET /cards'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Create Card ─── @@ -38,47 +43,55 @@ export async function cardRoutes(app: FastifyInstance) { return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); } - // Verify every supplied link belongs to the authenticated user - if (parsed.data.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: parsed.data.linkIds }, userId }, - select: { id: true }, - }); + try { + // Verify every supplied link belongs to the authenticated user before any write. + // A count mismatch means at least one ID is foreign — reject before touching the DB. + if (parsed.data.linkIds.length > 0) { + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { id: true }, + }); - if (ownedLinks.length !== parsed.data.linkIds.length) { - return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + if (ownedLinks.length !== parsed.data.linkIds.length) { + return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + } } - } - // Check if user's first card → make it default - const cardCount = await app.prisma.card.count({ where: { userId } }); - - const card = await app.prisma.card.create({ - data: { - userId, - title: parsed.data.title, - isDefault: cardCount === 0, - cardLinks: { - create: parsed.data.linkIds.map((linkId, index) => ({ - platformLinkId: linkId, - displayOrder: index, - })), + // Check if user's first card → make it default. + // Prisma wraps the nested cardLinks.create inside card.create in a single + // implicit transaction, so either both the card and its links are written or neither is. + const cardCount = await app.prisma.card.count({ where: { userId } }); + + const card = await app.prisma.card.create({ + data: { + userId, + title: parsed.data.title, + isDefault: cardCount === 0, + cardLinks: { + create: parsed.data.linkIds.map((linkId, index) => ({ + platformLinkId: linkId, + displayOrder: index, + })), + }, }, - }, - include: { - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, }, - }, - }); - - return reply.status(201).send({ - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cl) => cl.platformLink), - }); + }); + + return reply.status(201).send({ + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cl) => cl.platformLink), + }); + } catch (err) { + app.log.error({ err }, 'DB error in POST /cards'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Update Card ─── @@ -87,70 +100,78 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); - - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - const parsed = updateCardSchema.safeParse(request.body); - if (!parsed.success) { - return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); - } + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } - // Update card title - if (parsed.data.title) { - await app.prisma.card.update({ - where: { id }, - data: { title: parsed.data.title }, - }); - } + const parsed = updateCardSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); + } - // Update card links if provided - if (parsed.data.linkIds) { - // Verify every supplied link belongs to the authenticated user - if (parsed.data.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: parsed.data.linkIds }, userId }, - select: { id: true }, + if (parsed.data.title) { + await app.prisma.card.update({ + where: { id }, + data: { title: parsed.data.title }, }); + } - if (ownedLinks.length !== parsed.data.linkIds.length) { - return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + if (parsed.data.linkIds) { + // Ownership check runs before any write so a foreign linkId is always + // caught before existing links are touched. + if (parsed.data.linkIds.length > 0) { + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== parsed.data.linkIds.length) { + return reply.status(403).send({ error: 'One or more links do not belong to your account' }); + } } + + // Replace links inside a transaction so the card is never left linkless + // when deleteMany succeeds but createMany subsequently fails. + const linkIds = parsed.data.linkIds; + await app.prisma.$transaction(async (tx) => { + await tx.cardLink.deleteMany({ where: { cardId: id } }); + if (linkIds.length > 0) { + await tx.cardLink.createMany({ + data: linkIds.map((linkId, index) => ({ + cardId: id, + platformLinkId: linkId, + displayOrder: index, + })), + }); + } + }); } - // Remove existing links - await app.prisma.cardLink.deleteMany({ where: { cardId: id } }); - // Add new links - await app.prisma.cardLink.createMany({ - data: parsed.data.linkIds.map((linkId, index) => ({ - cardId: id, - platformLinkId: linkId, - displayOrder: index, - })), + const updated = await app.prisma.card.findUnique({ + where: { id }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, + }, }); - } - // Fetch updated card - const updated = await app.prisma.card.findUnique({ - where: { id }, - include: { - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, - }, - }, - }); - - return { - id: updated!.id, - title: updated!.title, - isDefault: updated!.isDefault, - links: updated!.cardLinks.map((cl) => cl.platformLink), - }; + return { + id: updated!.id, + title: updated!.title, + isDefault: updated!.isDefault, + links: updated!.cardLinks.map((cl) => cl.platformLink), + }; + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in PUT /cards/:id'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Delete Card ─── @@ -159,16 +180,21 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } - await app.prisma.card.delete({ where: { id } }); - return reply.status(204).send(); + await app.prisma.card.delete({ where: { id } }); + return reply.status(204).send(); + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in DELETE /cards/:id'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Set Default Card ─── @@ -177,26 +203,26 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); - - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - // Unset all other defaults - await app.prisma.card.updateMany({ - where: { userId }, - data: { isDefault: false }, - }); + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } - // Set this one - await app.prisma.card.update({ - where: { id }, - data: { isDefault: true }, - }); + // Clear then set in a single transaction so there is never a window where + // the user has zero default cards if the second write fails. + await app.prisma.$transaction(async (tx) => { + await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); + await tx.card.update({ where: { id }, data: { isDefault: true } }); + }); - return { message: 'Default card updated' }; + return { message: 'Default card updated' }; + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in PUT /cards/:id/default'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); }