Skip to content

Commit dda5ecb

Browse files
committed
fix(cards): address lint feedback
1 parent bc1cc06 commit dda5ecb

3 files changed

Lines changed: 366 additions & 70 deletions

File tree

) address lint feedback

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
warning: in the working copy of 'apps/backend/src/services/cardService.ts', LF will be replaced by CRLF the next time Git touches it
2+
diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts
3+
index 2556c72..1bc4ce0 100644
4+
--- a/apps/backend/src/services/cardService.ts
5+
+++ b/apps/backend/src/services/cardService.ts
6+
@@ -1,105 +1,174 @@
7+
-import type { FastifyInstance } from 'fastify'
8+
-import type { Prisma } from '@prisma/client'
9+
+import type { Prisma } from '@prisma/client';
10+
+import type { FastifyInstance } from 'fastify';
11+
+
12+
+type CardLinkResponse = { platformLink: unknown };
13+
+type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] };
14+
+type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] };
15+
+
16+
+function mapCard(card: RawCard): CardResponse {
17+
+ return {
18+
+ id: card.id,
19+
+ title: card.title,
20+
+ isDefault: card.isDefault,
21+
+ links: card.cardLinks.map((cardLink) => cardLink.platformLink),
22+
+ };
23+
+}
24+

25+
-export async function listCards(app: FastifyInstance, userId: string) {
26+
- const cards = await app.prisma.card.findMany({
27+
+export async function listCards(app: FastifyInstance, userId: string): Promise<CardResponse[]> {
28+
+ const cards = (await app.prisma.card.findMany({
29+
where: { userId },
30+
take: 50,
31+
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
32+
orderBy: { createdAt: 'asc' },
33+
- })
34+
+ })) as unknown as RawCard[];
35+

36+
- return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }))
37+
+ return cards.map(mapCard);
38+
}
39+

40+
-export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {
41+
+export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise<CardResponse> {
42+
if (body.linkIds.length > 0) {
43+
- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })
44+
- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
45+
+ const ownedLinks = await app.prisma.platformLink.findMany({
46+
+ where: { id: { in: body.linkIds }, userId },
47+
+ select: { id: true },
48+
+ });
49+
+
50+
+ if (ownedLinks.length !== body.linkIds.length) {
51+
+ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
52+
+ }
53+
}
54+

55+
- const MAX_RETRIES = 3;
56+
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
57+
+ const maxRetries = 3;
58+
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
59+
try {
60+
- const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
61+
- const cardCount = await tx.card.count({ where: { userId } })
62+
-
63+
- return await tx.card.create({
64+
- data: {
65+
- userId,
66+
- title: body.title,
67+
- isDefault: cardCount === 0,
68+
- cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) },
69+
- },
70+
- include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
71+
- })
72+
- }, {
73+
- isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel
74+
- })
75+
-
76+
- return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
77+
- } catch (error: any) {
78+
- if (error.code === 'P2034' && attempt < MAX_RETRIES) continue;
79+
+ const card = (await app.prisma.$transaction(
80+
+ async (tx: Prisma.TransactionClient) => {
81+
+ const cardCount = await tx.card.count({ where: { userId } });
82+
+
83+
+ return tx.card.create({
84+
+ data: {
85+
+ userId,
86+
+ title: body.title,
87+
+ isDefault: cardCount === 0,
88+
+ cardLinks: {
89+
+ create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })),
90+
+ },
91+
+ },
92+
+ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
93+
+ });
94+
+ },
95+
+ {
96+
+ isolationLevel: 'Serializable',
97+
+ },
98+
+ )) as unknown as RawCard;
99+
+
100+
+ return mapCard(card);
101+
+ } catch (error: unknown) {
102+
+ if (
103+
+ typeof error === 'object' &&
104+
+ error !== null &&
105+
+ 'code' in error &&
106+
+ (error as { code: string }).code === 'P2034' &&
107+
+ attempt < maxRetries
108+
+ ) {
109+
+ continue;
110+
+ }
111+
+
112+
+ app.log.error(error);
113+
throw error;
114+
}
115+
}
116+
+
117+
+ throw new Error('Failed to create card after retrying serialization conflicts');
118+
}
119+

120+
-export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) {
121+
- const existing = await app.prisma.card.findFirst({ where: { id, userId } })
122+
- if (!existing) return null
123+
+export async function updateCard(
124+
+ app: FastifyInstance,
125+
+ userId: string,
126+
+ id: string,
127+
+ body: { title?: string; linkIds?: string[] },
128+
+): Promise<CardResponse | null> {
129+
+ const existing = await app.prisma.card.findFirst({ where: { id, userId } });
130+
+ if (!existing) {
131+
+ return null;
132+
+ }
133+

134+
if (body.title) {
135+
- await app.prisma.card.update({ where: { id }, data: { title: body.title } })
136+
+ await app.prisma.card.update({ where: { id }, data: { title: body.title } });
137+
}
138+

139+
if (body.linkIds) {
140+
if (body.linkIds.length > 0) {
141+
- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })
142+
- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
143+
+ const ownedLinks = await app.prisma.platformLink.findMany({
144+
+ where: { id: { in: body.linkIds }, userId },
145+
+ select: { id: true },
146+
+ });
147+
+
148+
+ if (ownedLinks.length !== body.linkIds.length) {
149+
+ throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
150+
+ }
151+
}
152+

153+
- const linkIds = body.linkIds
154+
+ const linkIds = body.linkIds;
155+
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
156+
- await tx.cardLink.deleteMany({ where: { cardId: id } })
157+
+ await tx.cardLink.deleteMany({ where: { cardId: id } });
158+
if (linkIds.length > 0) {
159+
- await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) })
160+
+ await tx.cardLink.createMany({
161+
+ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })),
162+
+ });
163+
}
164+
- })
165+
+ });
166+
}
167+

168+
- const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })
169+
- return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) }
170+
+ const updated = (await app.prisma.card.findUnique({
171+
+ where: { id },
172+
+ include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
173+
+ })) as unknown as RawCard | null;
174+
+
175+
+ if (!updated) {
176+
+ return null;
177+
+ }
178+
+
179+
+ return mapCard(updated);
180+
}
181+

182+
-export async function deleteCard(app: FastifyInstance, userId: string, id: string) {
183+
+export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise<null> {
184+
return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
185+
- const existing = await tx.card.findFirst({ where: { id, userId } })
186+
- if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
187+
+ const existing = await tx.card.findFirst({ where: { id, userId } });
188+
+ if (!existing) {
189+
+ return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' });
190+
+ }
191+

192+
- const userCardCount = await tx.card.count({ where: { userId } })
193+
- if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })
194+
+ const userCardCount = await tx.card.count({ where: { userId } });
195+
+ if (userCardCount <= 1) {
196+
+ return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' });
197+
+ }
198+

199+
if (existing.isDefault) {
200+
- const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } })
201+
+ const oldestRemainingCard = await tx.card.findFirst({
202+
+ where: { userId, id: { not: id } },
203+
+ orderBy: { createdAt: 'asc' },
204+
+ });
205+
+
206+
if (oldestRemainingCard) {
207+
- await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } })
208+
+ await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
209+
}
210+
}
211+

212+
- await tx.card.delete({ where: { id } })
213+
- return null
214+
- })
215+
+ await tx.card.delete({ where: { id } });
216+
+ return null;
217+
+ });
218+
}
219+

220+
-export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) {
221+
- const existing = await app.prisma.card.findFirst({ where: { id, userId } })
222+
- if (!existing) return null
223+
+export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> {
224+
+ const existing = await app.prisma.card.findFirst({ where: { id, userId } });
225+
+ if (!existing) {
226+
+ return null;
227+
+ }
228+

229+
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
230+
- await tx.card.updateMany({ where: { userId }, data: { isDefault: false } })
231+
- await tx.card.update({ where: { id }, data: { isDefault: true } })
232+
- })
233+
+ await tx.card.updateMany({ where: { userId }, data: { isDefault: false } });
234+
+ await tx.card.update({ where: { id }, data: { isDefault: true } });
235+
+ });
236+

237+
- return { message: 'Default card updated' }
238+
+ return { message: 'Default card updated' };
239+
}

apps/backend/src/__tests__/cards.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
12
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import Fastify, { FastifyRequest } from 'fastify';
3-
import { Prisma } from '@prisma/client';
3+
44
import { cardRoutes } from '../routes/cards.js';
55

66
const USER_ID = 'user-123';
@@ -44,13 +44,13 @@ const mockPrisma = {
4444

4545
// Re-wire $transaction before every test so that it executes the callback
4646
// against the same mock client, preserving existing per-operation mocks.
47-
function wireTransaction() {
47+
function wireTransaction(): void {
4848
mockPrisma.$transaction.mockImplementation(
49-
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, options?: unknown) => callback(mockPrisma),
49+
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, _options?: unknown) => callback(mockPrisma),
5050
);
5151
}
5252

53-
async function buildApp() {
53+
async function buildApp(): Promise<FastifyInstance> {
5454
const app = Fastify({ logger: false });
5555
app.decorate('prisma', mockPrisma);
5656
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {

0 commit comments

Comments
 (0)