From 02beb8ec3bc800736f9e01331a5050c8f764bc98 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Fri, 29 May 2026 13:47:35 +0530 Subject: [PATCH 1/4] fix(cards): prevent concurrent default card race condition (#344) --- apps/backend/src/__tests__/cards.test.ts | 51 +++++++++++++++++++++++- apps/backend/src/services/cardService.ts | 38 ++++++++++++------ 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 813883e8..62c3b518 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -45,7 +45,7 @@ const mockPrisma = { // against the same mock client, preserving existing per-operation mocks. function wireTransaction() { mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), + async (callback: (tx: typeof mockPrisma) => Promise, options?: any) => callback(mockPrisma), ); } @@ -179,6 +179,55 @@ describe('POST /api/cards — link ownership validation', () => { expect(res.statusCode).toBe(500); }); + + it('wraps creation in a Serializable transaction to prevent race conditions', async () => { + 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.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { isolationLevel: 'Serializable' } + ); + }); + + it('retries the transaction on P2034 serialization failure', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + // First attempt fails with P2034 (serialization conflict) + // Second attempt succeeds + const error = new Error('Serialization failure'); + (error as any).code = 'P2034'; + + // We mock $transaction to fail once, then succeed + mockPrisma.$transaction + .mockRejectedValueOnce(error) + .mockImplementationOnce( + async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma) + ); + + mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0 + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().isDefault).toBe(false); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..2556c72b 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -18,19 +18,31 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) } - const cardCount = await app.prisma.card.count({ where: { userId } }) - - const card = await app.prisma.card.create({ - data: { - userId, - title: body.title, - isDefault: cardCount === 0, - cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, - }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - }) - - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } + const MAX_RETRIES = 3; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const cardCount = await tx.card.count({ where: { userId } }) + + return await tx.card.create({ + data: { + userId, + title: body.title, + isDefault: cardCount === 0, + cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, + }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }) + }, { + isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel + }) + + return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } + } catch (error: any) { + if (error.code === 'P2034' && attempt < MAX_RETRIES) continue; + throw error; + } + } } export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { From bc1cc063bc0fb157e3b692fb34f828f9d3321870 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Sat, 30 May 2026 11:18:25 +0530 Subject: [PATCH 2/4] fix(cards): address review feedback --- apps/backend/src/__tests__/cards.test.ts | 11 ++++---- apps/backend/src/services/cardService.ts | 34 ++++++++++++++++-------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 62c3b518..d2d0950b 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; +import Fastify, { FastifyRequest } from 'fastify'; +import { Prisma } from '@prisma/client'; import { cardRoutes } from '../routes/cards.js'; const USER_ID = 'user-123'; @@ -45,14 +46,14 @@ const mockPrisma = { // against the same mock client, preserving existing per-operation mocks. function wireTransaction() { mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise, options?: any) => callback(mockPrisma), + async (callback: (tx: typeof mockPrisma) => Promise, options?: unknown) => callback(mockPrisma), ); } async function buildApp() { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma); - app.decorate('authenticate', async (request: any) => { + app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => { request.user = { id: USER_ID }; }); app.register(cardRoutes, { prefix: '/api/cards' }); @@ -204,8 +205,8 @@ describe('POST /api/cards — link ownership validation', () => { // First attempt fails with P2034 (serialization conflict) // Second attempt succeeds - const error = new Error('Serialization failure'); - (error as any).code = 'P2034'; + const error = new Error('Serialization failure') as Error & { code: string }; + error.code = 'P2034'; // We mock $transaction to fail once, then succeed mockPrisma.$transaction diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 2556c72b..95f13b77 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,5 +1,7 @@ import type { FastifyInstance } from 'fastify' -import type { Prisma } from '@prisma/client' + + +type RawCard = { id: string, title: string, isDefault: boolean, cardLinks: { platformLink: unknown }[] }; export async function listCards(app: FastifyInstance, userId: string) { const cards = await app.prisma.card.findMany({ @@ -7,9 +9,9 @@ export async function listCards(app: FastifyInstance, userId: string) { take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - }) + }) as unknown as RawCard[]; - return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) + return cards.map((card) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl) => cl.platformLink) })) } export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { @@ -34,12 +36,21 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, }) }, { - isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel - }) - - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } - } catch (error: any) { - if (error.code === 'P2034' && attempt < MAX_RETRIES) continue; + isolationLevel: 'Serializable' + }) as unknown as RawCard; + + return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl) => cl.platformLink) } + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'P2034' && + attempt < MAX_RETRIES + ) { + continue; + } + app.log.error(error); throw error; } } @@ -68,8 +79,9 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin }) } - 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: any) => cl.platformLink) } + const updated = (await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })) as unknown as RawCard | null; + if (!updated) return null; + return { id: updated.id, title: updated.title, isDefault: updated.isDefault, links: updated.cardLinks.map((cl) => cl.platformLink) } } export async function deleteCard(app: FastifyInstance, userId: string, id: string) { From dda5ecb8d99e0af93272e87d087be154bf3446a7 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Tue, 2 Jun 2026 17:21:32 +0530 Subject: [PATCH 3/4] fix(cards): address lint feedback --- ...200\272 address lint feedback\357\200\242" | 239 ++++++++++++++++++ apps/backend/src/__tests__/cards.test.ts | 10 +- apps/backend/src/services/cardService.ts | 187 +++++++++----- 3 files changed, 366 insertions(+), 70 deletions(-) create mode 100644 ")\357\200\272 address lint feedback\357\200\242" diff --git "a/)\357\200\272 address lint feedback\357\200\242" "b/)\357\200\272 address lint feedback\357\200\242" new file mode 100644 index 00000000..6532c742 --- /dev/null +++ "b/)\357\200\272 address lint feedback\357\200\242" @@ -0,0 +1,239 @@ +warning: in the working copy of 'apps/backend/src/services/cardService.ts', LF will be replaced by CRLF the next time Git touches it +diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts +index 2556c72..1bc4ce0 100644 +--- a/apps/backend/src/services/cardService.ts ++++ b/apps/backend/src/services/cardService.ts +@@ -1,105 +1,174 @@ +-import type { FastifyInstance } from 'fastify' +-import type { Prisma } from '@prisma/client' ++import type { Prisma } from '@prisma/client'; ++import type { FastifyInstance } from 'fastify'; ++ ++type CardLinkResponse = { platformLink: unknown }; ++type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; ++type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; ++ ++function mapCard(card: RawCard): CardResponse { ++ return { ++ id: card.id, ++ title: card.title, ++ isDefault: card.isDefault, ++ links: card.cardLinks.map((cardLink) => cardLink.platformLink), ++ }; ++} +  +-export async function listCards(app: FastifyInstance, userId: string) { +- const cards = await app.prisma.card.findMany({ ++export async function listCards(app: FastifyInstance, userId: string): Promise { ++ const cards = (await app.prisma.card.findMany({ + where: { userId }, + take: 50, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + orderBy: { createdAt: 'asc' }, +- }) ++ })) as unknown as RawCard[]; +  +- return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) ++ return cards.map(mapCard); + } +  +-export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { ++export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise { + if (body.linkIds.length > 0) { +- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) +- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) ++ const ownedLinks = await app.prisma.platformLink.findMany({ ++ where: { id: { in: body.linkIds }, userId }, ++ select: { id: true }, ++ }); ++ ++ if (ownedLinks.length !== body.linkIds.length) { ++ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); ++ } + } +  +- const MAX_RETRIES = 3; +- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { ++ const maxRetries = 3; ++ for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { +- const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { +- const cardCount = await tx.card.count({ where: { userId } }) +- +- return await tx.card.create({ +- data: { +- userId, +- title: body.title, +- isDefault: cardCount === 0, +- cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, +- }, +- include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, +- }) +- }, { +- isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel +- }) +- +- return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } +- } catch (error: any) { +- if (error.code === 'P2034' && attempt < MAX_RETRIES) continue; ++ const card = (await app.prisma.$transaction( ++ async (tx: Prisma.TransactionClient) => { ++ const cardCount = await tx.card.count({ where: { userId } }); ++ ++ return tx.card.create({ ++ data: { ++ userId, ++ title: body.title, ++ isDefault: cardCount === 0, ++ cardLinks: { ++ create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), ++ }, ++ }, ++ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, ++ }); ++ }, ++ { ++ isolationLevel: 'Serializable', ++ }, ++ )) as unknown as RawCard; ++ ++ return mapCard(card); ++ } catch (error: unknown) { ++ if ( ++ typeof error === 'object' && ++ error !== null && ++ 'code' in error && ++ (error as { code: string }).code === 'P2034' && ++ attempt < maxRetries ++ ) { ++ continue; ++ } ++ ++ app.log.error(error); + throw error; + } + } ++ ++ throw new Error('Failed to create card after retrying serialization conflicts'); + } +  +-export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { +- const existing = await app.prisma.card.findFirst({ where: { id, userId } }) +- if (!existing) return null ++export async function updateCard( ++ app: FastifyInstance, ++ userId: string, ++ id: string, ++ body: { title?: string; linkIds?: string[] }, ++): Promise { ++ const existing = await app.prisma.card.findFirst({ where: { id, userId } }); ++ if (!existing) { ++ return null; ++ } +  + if (body.title) { +- await app.prisma.card.update({ where: { id }, data: { title: body.title } }) ++ await app.prisma.card.update({ where: { id }, data: { title: body.title } }); + } +  + if (body.linkIds) { + if (body.linkIds.length > 0) { +- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) +- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) ++ const ownedLinks = await app.prisma.platformLink.findMany({ ++ where: { id: { in: body.linkIds }, userId }, ++ select: { id: true }, ++ }); ++ ++ if (ownedLinks.length !== body.linkIds.length) { ++ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); ++ } + } +  +- const linkIds = body.linkIds ++ const linkIds = body.linkIds; + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { +- await tx.cardLink.deleteMany({ where: { cardId: id } }) ++ 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 })) }) ++ await tx.cardLink.createMany({ ++ 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' } } } }) +- return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } ++ const updated = (await app.prisma.card.findUnique({ ++ where: { id }, ++ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, ++ })) as unknown as RawCard | null; ++ ++ if (!updated) { ++ return null; ++ } ++ ++ return mapCard(updated); + } +  +-export async function deleteCard(app: FastifyInstance, userId: string, id: string) { ++export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { + return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { +- const existing = await tx.card.findFirst({ where: { id, userId } }) +- if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) ++ const existing = await tx.card.findFirst({ where: { id, userId } }); ++ if (!existing) { ++ return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); ++ } +  +- const userCardCount = await tx.card.count({ where: { userId } }) +- if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) ++ const userCardCount = await tx.card.count({ where: { userId } }); ++ if (userCardCount <= 1) { ++ return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); ++ } +  + if (existing.isDefault) { +- const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) ++ const oldestRemainingCard = await tx.card.findFirst({ ++ where: { userId, id: { not: id } }, ++ orderBy: { createdAt: 'asc' }, ++ }); ++ + if (oldestRemainingCard) { +- await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) ++ await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); + } + } +  +- await tx.card.delete({ where: { id } }) +- return null +- }) ++ await tx.card.delete({ where: { id } }); ++ return null; ++ }); + } +  +-export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { +- const existing = await app.prisma.card.findFirst({ where: { id, userId } }) +- if (!existing) return null ++export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { ++ const existing = await app.prisma.card.findFirst({ where: { id, userId } }); ++ if (!existing) { ++ return null; ++ } +  + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { +- await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) +- await tx.card.update({ where: { id }, data: { isDefault: true } }) +- }) ++ 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' }; + } diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index d2d0950b..b034dba6 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,6 +1,6 @@ +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify, { FastifyRequest } from 'fastify'; -import { Prisma } from '@prisma/client'; + import { cardRoutes } from '../routes/cards.js'; const USER_ID = 'user-123'; @@ -44,13 +44,13 @@ const mockPrisma = { // Re-wire $transaction before every test so that it executes the callback // against the same mock client, preserving existing per-operation mocks. -function wireTransaction() { +function wireTransaction(): void { mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise, options?: unknown) => callback(mockPrisma), + async (callback: (tx: typeof mockPrisma) => Promise, _options?: unknown) => callback(mockPrisma), ); } -async function buildApp() { +async function buildApp(): Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma); app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => { diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index 95f13b77..1bc4ce03 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,117 +1,174 @@ -import type { FastifyInstance } from 'fastify' - - -type RawCard = { id: string, title: string, isDefault: boolean, cardLinks: { platformLink: unknown }[] }; +import type { Prisma } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +type CardLinkResponse = { platformLink: unknown }; +type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; +type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; + +function mapCard(card: RawCard): CardResponse { + return { + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cardLink) => cardLink.platformLink), + }; +} -export async function listCards(app: FastifyInstance, userId: string) { - const cards = await app.prisma.card.findMany({ +export async function listCards(app: FastifyInstance, userId: string): Promise { + const cards = (await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - }) as unknown as RawCard[]; + })) as unknown as RawCard[]; - return cards.map((card) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl) => cl.platformLink) })) + return cards.map(mapCard); } -export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { +export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise { if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: body.linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== body.linkIds.length) { + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); + } } - const MAX_RETRIES = 3; - for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const cardCount = await tx.card.count({ where: { userId } }) - - return await tx.card.create({ - data: { - userId, - title: body.title, - isDefault: cardCount === 0, - cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, - }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - }) - }, { - isolationLevel: 'Serializable' - }) as unknown as RawCard; - - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl) => cl.platformLink) } + const card = (await app.prisma.$transaction( + async (tx: Prisma.TransactionClient) => { + const cardCount = await tx.card.count({ where: { userId } }); + + return tx.card.create({ + data: { + userId, + title: body.title, + isDefault: cardCount === 0, + cardLinks: { + create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), + }, + }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }); + }, + { + isolationLevel: 'Serializable', + }, + )) as unknown as RawCard; + + return mapCard(card); } catch (error: unknown) { if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: string }).code === 'P2034' && - attempt < MAX_RETRIES + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'P2034' && + attempt < maxRetries ) { continue; } + app.log.error(error); throw error; } } + + throw new Error('Failed to create card after retrying serialization conflicts'); } -export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null +export async function updateCard( + app: FastifyInstance, + userId: string, + id: string, + body: { title?: string; linkIds?: string[] }, +): Promise { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + return null; + } if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + await app.prisma.card.update({ where: { id }, data: { title: body.title } }); } if (body.linkIds) { if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: body.linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== body.linkIds.length) { + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); + } } - const linkIds = body.linkIds + const linkIds = body.linkIds; await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }) + 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 })) }) + await tx.cardLink.createMany({ + 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' } } }, + })) as unknown as RawCard | null; + + if (!updated) { + return null; } - const updated = (await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })) as unknown as RawCard | null; - if (!updated) return null; - return { id: updated.id, title: updated.title, isDefault: updated.isDefault, links: updated.cardLinks.map((cl) => cl.platformLink) } + return mapCard(updated); } -export async function deleteCard(app: FastifyInstance, userId: string, id: string) { +export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + const existing = await tx.card.findFirst({ where: { id, userId } }); + if (!existing) { + return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + } - const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + const userCardCount = await tx.card.count({ where: { userId } }); + if (userCardCount <= 1) { + return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); + } if (existing.isDefault) { - const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) + const oldestRemainingCard = await tx.card.findFirst({ + where: { userId, id: { not: id } }, + orderBy: { createdAt: 'asc' }, + }); + if (oldestRemainingCard) { - await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) + await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); } } - await tx.card.delete({ where: { id } }) - return null - }) + await tx.card.delete({ where: { id } }); + return null; + }); } -export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null +export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + return null; + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) - await tx.card.update({ where: { id }, data: { isDefault: true } }) - }) + 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' }; } From 341d538929761760ae246678ecd3fabb8fa5ed47 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Wed, 3 Jun 2026 10:33:01 +0530 Subject: [PATCH 4/4] chore: remove accidental file from PR --- ...200\272 address lint feedback\357\200\242" | 239 ------------------ 1 file changed, 239 deletions(-) delete mode 100644 ")\357\200\272 address lint feedback\357\200\242" diff --git "a/)\357\200\272 address lint feedback\357\200\242" "b/)\357\200\272 address lint feedback\357\200\242" deleted file mode 100644 index 6532c742..00000000 --- "a/)\357\200\272 address lint feedback\357\200\242" +++ /dev/null @@ -1,239 +0,0 @@ -warning: in the working copy of 'apps/backend/src/services/cardService.ts', LF will be replaced by CRLF the next time Git touches it -diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts -index 2556c72..1bc4ce0 100644 ---- a/apps/backend/src/services/cardService.ts -+++ b/apps/backend/src/services/cardService.ts -@@ -1,105 +1,174 @@ --import type { FastifyInstance } from 'fastify' --import type { Prisma } from '@prisma/client' -+import type { Prisma } from '@prisma/client'; -+import type { FastifyInstance } from 'fastify'; -+ -+type CardLinkResponse = { platformLink: unknown }; -+type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; -+type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; -+ -+function mapCard(card: RawCard): CardResponse { -+ return { -+ id: card.id, -+ title: card.title, -+ isDefault: card.isDefault, -+ links: card.cardLinks.map((cardLink) => cardLink.platformLink), -+ }; -+} -  --export async function listCards(app: FastifyInstance, userId: string) { -- const cards = await app.prisma.card.findMany({ -+export async function listCards(app: FastifyInstance, userId: string): Promise { -+ const cards = (await app.prisma.card.findMany({ - where: { userId }, - take: 50, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - orderBy: { createdAt: 'asc' }, -- }) -+ })) as unknown as RawCard[]; -  -- return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) -+ return cards.map(mapCard); - } -  --export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { -+export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise { - if (body.linkIds.length > 0) { -- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) -- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) -+ const ownedLinks = await app.prisma.platformLink.findMany({ -+ where: { id: { in: body.linkIds }, userId }, -+ select: { id: true }, -+ }); -+ -+ if (ownedLinks.length !== body.linkIds.length) { -+ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); -+ } - } -  -- const MAX_RETRIES = 3; -- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { -+ const maxRetries = 3; -+ for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { -- const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { -- const cardCount = await tx.card.count({ where: { userId } }) -- -- return await tx.card.create({ -- data: { -- userId, -- title: body.title, -- isDefault: cardCount === 0, -- cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, -- }, -- include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, -- }) -- }, { -- isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel -- }) -- -- return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } -- } catch (error: any) { -- if (error.code === 'P2034' && attempt < MAX_RETRIES) continue; -+ const card = (await app.prisma.$transaction( -+ async (tx: Prisma.TransactionClient) => { -+ const cardCount = await tx.card.count({ where: { userId } }); -+ -+ return tx.card.create({ -+ data: { -+ userId, -+ title: body.title, -+ isDefault: cardCount === 0, -+ cardLinks: { -+ create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), -+ }, -+ }, -+ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, -+ }); -+ }, -+ { -+ isolationLevel: 'Serializable', -+ }, -+ )) as unknown as RawCard; -+ -+ return mapCard(card); -+ } catch (error: unknown) { -+ if ( -+ typeof error === 'object' && -+ error !== null && -+ 'code' in error && -+ (error as { code: string }).code === 'P2034' && -+ attempt < maxRetries -+ ) { -+ continue; -+ } -+ -+ app.log.error(error); - throw error; - } - } -+ -+ throw new Error('Failed to create card after retrying serialization conflicts'); - } -  --export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { -- const existing = await app.prisma.card.findFirst({ where: { id, userId } }) -- if (!existing) return null -+export async function updateCard( -+ app: FastifyInstance, -+ userId: string, -+ id: string, -+ body: { title?: string; linkIds?: string[] }, -+): Promise { -+ const existing = await app.prisma.card.findFirst({ where: { id, userId } }); -+ if (!existing) { -+ return null; -+ } -  - if (body.title) { -- await app.prisma.card.update({ where: { id }, data: { title: body.title } }) -+ await app.prisma.card.update({ where: { id }, data: { title: body.title } }); - } -  - if (body.linkIds) { - if (body.linkIds.length > 0) { -- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) -- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) -+ const ownedLinks = await app.prisma.platformLink.findMany({ -+ where: { id: { in: body.linkIds }, userId }, -+ select: { id: true }, -+ }); -+ -+ if (ownedLinks.length !== body.linkIds.length) { -+ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); -+ } - } -  -- const linkIds = body.linkIds -+ const linkIds = body.linkIds; - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { -- await tx.cardLink.deleteMany({ where: { cardId: id } }) -+ 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 })) }) -+ await tx.cardLink.createMany({ -+ 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' } } } }) -- return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } -+ const updated = (await app.prisma.card.findUnique({ -+ where: { id }, -+ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, -+ })) as unknown as RawCard | null; -+ -+ if (!updated) { -+ return null; -+ } -+ -+ return mapCard(updated); - } -  --export async function deleteCard(app: FastifyInstance, userId: string, id: string) { -+export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { - return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { -- const existing = await tx.card.findFirst({ where: { id, userId } }) -- if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) -+ const existing = await tx.card.findFirst({ where: { id, userId } }); -+ if (!existing) { -+ return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); -+ } -  -- const userCardCount = await tx.card.count({ where: { userId } }) -- if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) -+ const userCardCount = await tx.card.count({ where: { userId } }); -+ if (userCardCount <= 1) { -+ return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); -+ } -  - if (existing.isDefault) { -- const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) -+ const oldestRemainingCard = await tx.card.findFirst({ -+ where: { userId, id: { not: id } }, -+ orderBy: { createdAt: 'asc' }, -+ }); -+ - if (oldestRemainingCard) { -- await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) -+ await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); - } - } -  -- await tx.card.delete({ where: { id } }) -- return null -- }) -+ await tx.card.delete({ where: { id } }); -+ return null; -+ }); - } -  --export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { -- const existing = await app.prisma.card.findFirst({ where: { id, userId } }) -- if (!existing) return null -+export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { -+ const existing = await app.prisma.card.findFirst({ where: { id, userId } }); -+ if (!existing) { -+ return null; -+ } -  - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { -- await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) -- await tx.card.update({ where: { id }, data: { isDefault: true } }) -- }) -+ 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' }; - }