|
| 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 | +[1mdiff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts[m |
| 3 | +[1mindex 2556c72..1bc4ce0 100644[m |
| 4 | +[1m--- a/apps/backend/src/services/cardService.ts[m |
| 5 | +[1m+++ b/apps/backend/src/services/cardService.ts[m |
| 6 | +[36m@@ -1,105 +1,174 @@[m |
| 7 | +[31m-import type { FastifyInstance } from 'fastify'[m |
| 8 | +[31m-import type { Prisma } from '@prisma/client'[m |
| 9 | +[32m+[m[32mimport type { Prisma } from '@prisma/client';[m |
| 10 | +[32m+[m[32mimport type { FastifyInstance } from 'fastify';[m |
| 11 | +[32m+[m |
| 12 | +[32m+[m[32mtype CardLinkResponse = { platformLink: unknown };[m |
| 13 | +[32m+[m[32mtype RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] };[m |
| 14 | +[32m+[m[32mtype CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] };[m |
| 15 | +[32m+[m |
| 16 | +[32m+[m[32mfunction mapCard(card: RawCard): CardResponse {[m |
| 17 | +[32m+[m[32m return {[m |
| 18 | +[32m+[m[32m id: card.id,[m |
| 19 | +[32m+[m[32m title: card.title,[m |
| 20 | +[32m+[m[32m isDefault: card.isDefault,[m |
| 21 | +[32m+[m[32m links: card.cardLinks.map((cardLink) => cardLink.platformLink),[m |
| 22 | +[32m+[m[32m };[m |
| 23 | +[32m+[m[32m}[m |
| 24 | + [m |
| 25 | +[31m-export async function listCards(app: FastifyInstance, userId: string) {[m |
| 26 | +[31m- const cards = await app.prisma.card.findMany({[m |
| 27 | +[32m+[m[32mexport async function listCards(app: FastifyInstance, userId: string): Promise<CardResponse[]> {[m |
| 28 | +[32m+[m[32m const cards = (await app.prisma.card.findMany({[m |
| 29 | + where: { userId },[m |
| 30 | + take: 50,[m |
| 31 | + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },[m |
| 32 | + orderBy: { createdAt: 'asc' },[m |
| 33 | +[31m- })[m |
| 34 | +[32m+[m[32m })) as unknown as RawCard[];[m |
| 35 | + [m |
| 36 | +[31m- return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }))[m |
| 37 | +[32m+[m[32m return cards.map(mapCard);[m |
| 38 | + }[m |
| 39 | + [m |
| 40 | +[31m-export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {[m |
| 41 | +[32m+[m[32mexport async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise<CardResponse> {[m |
| 42 | + if (body.linkIds.length > 0) {[m |
| 43 | +[31m- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })[m |
| 44 | +[31m- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })[m |
| 45 | +[32m+[m[32m const ownedLinks = await app.prisma.platformLink.findMany({[m |
| 46 | +[32m+[m[32m where: { id: { in: body.linkIds }, userId },[m |
| 47 | +[32m+[m[32m select: { id: true },[m |
| 48 | +[32m+[m[32m });[m |
| 49 | +[32m+[m |
| 50 | +[32m+[m[32m if (ownedLinks.length !== body.linkIds.length) {[m |
| 51 | +[32m+[m[32m throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });[m |
| 52 | +[32m+[m[32m }[m |
| 53 | + }[m |
| 54 | + [m |
| 55 | +[31m- const MAX_RETRIES = 3;[m |
| 56 | +[31m- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {[m |
| 57 | +[32m+[m[32m const maxRetries = 3;[m |
| 58 | +[32m+[m[32m for (let attempt = 1; attempt <= maxRetries; attempt++) {[m |
| 59 | + try {[m |
| 60 | +[31m- const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {[m |
| 61 | +[31m- const cardCount = await tx.card.count({ where: { userId } })[m |
| 62 | +[31m-[m |
| 63 | +[31m- return await tx.card.create({[m |
| 64 | +[31m- data: {[m |
| 65 | +[31m- userId,[m |
| 66 | +[31m- title: body.title,[m |
| 67 | +[31m- isDefault: cardCount === 0,[m |
| 68 | +[31m- cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) },[m |
| 69 | +[31m- },[m |
| 70 | +[31m- include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },[m |
| 71 | +[31m- })[m |
| 72 | +[31m- }, {[m |
| 73 | +[31m- isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel[m |
| 74 | +[31m- })[m |
| 75 | +[31m-[m |
| 76 | +[31m- return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }[m |
| 77 | +[31m- } catch (error: any) {[m |
| 78 | +[31m- if (error.code === 'P2034' && attempt < MAX_RETRIES) continue;[m |
| 79 | +[32m+[m[32m const card = (await app.prisma.$transaction([m |
| 80 | +[32m+[m[32m async (tx: Prisma.TransactionClient) => {[m |
| 81 | +[32m+[m[32m const cardCount = await tx.card.count({ where: { userId } });[m |
| 82 | +[32m+[m |
| 83 | +[32m+[m[32m return tx.card.create({[m |
| 84 | +[32m+[m[32m data: {[m |
| 85 | +[32m+[m[32m userId,[m |
| 86 | +[32m+[m[32m title: body.title,[m |
| 87 | +[32m+[m[32m isDefault: cardCount === 0,[m |
| 88 | +[32m+[m[32m cardLinks: {[m |
| 89 | +[32m+[m[32m create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })),[m |
| 90 | +[32m+[m[32m },[m |
| 91 | +[32m+[m[32m },[m |
| 92 | +[32m+[m[32m include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },[m |
| 93 | +[32m+[m[32m });[m |
| 94 | +[32m+[m[32m },[m |
| 95 | +[32m+[m[32m {[m |
| 96 | +[32m+[m[32m isolationLevel: 'Serializable',[m |
| 97 | +[32m+[m[32m },[m |
| 98 | +[32m+[m[32m )) as unknown as RawCard;[m |
| 99 | +[32m+[m |
| 100 | +[32m+[m[32m return mapCard(card);[m |
| 101 | +[32m+[m[32m } catch (error: unknown) {[m |
| 102 | +[32m+[m[32m if ([m |
| 103 | +[32m+[m[32m typeof error === 'object' &&[m |
| 104 | +[32m+[m[32m error !== null &&[m |
| 105 | +[32m+[m[32m 'code' in error &&[m |
| 106 | +[32m+[m[32m (error as { code: string }).code === 'P2034' &&[m |
| 107 | +[32m+[m[32m attempt < maxRetries[m |
| 108 | +[32m+[m[32m ) {[m |
| 109 | +[32m+[m[32m continue;[m |
| 110 | +[32m+[m[32m }[m |
| 111 | +[32m+[m |
| 112 | +[32m+[m[32m app.log.error(error);[m |
| 113 | + throw error;[m |
| 114 | + }[m |
| 115 | + }[m |
| 116 | +[32m+[m |
| 117 | +[32m+[m[32m throw new Error('Failed to create card after retrying serialization conflicts');[m |
| 118 | + }[m |
| 119 | + [m |
| 120 | +[31m-export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) {[m |
| 121 | +[31m- const existing = await app.prisma.card.findFirst({ where: { id, userId } })[m |
| 122 | +[31m- if (!existing) return null[m |
| 123 | +[32m+[m[32mexport async function updateCard([m |
| 124 | +[32m+[m[32m app: FastifyInstance,[m |
| 125 | +[32m+[m[32m userId: string,[m |
| 126 | +[32m+[m[32m id: string,[m |
| 127 | +[32m+[m[32m body: { title?: string; linkIds?: string[] },[m |
| 128 | +[32m+[m[32m): Promise<CardResponse | null> {[m |
| 129 | +[32m+[m[32m const existing = await app.prisma.card.findFirst({ where: { id, userId } });[m |
| 130 | +[32m+[m[32m if (!existing) {[m |
| 131 | +[32m+[m[32m return null;[m |
| 132 | +[32m+[m[32m }[m |
| 133 | + [m |
| 134 | + if (body.title) {[m |
| 135 | +[31m- await app.prisma.card.update({ where: { id }, data: { title: body.title } })[m |
| 136 | +[32m+[m[32m await app.prisma.card.update({ where: { id }, data: { title: body.title } });[m |
| 137 | + }[m |
| 138 | + [m |
| 139 | + if (body.linkIds) {[m |
| 140 | + if (body.linkIds.length > 0) {[m |
| 141 | +[31m- const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })[m |
| 142 | +[31m- if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })[m |
| 143 | +[32m+[m[32m const ownedLinks = await app.prisma.platformLink.findMany({[m |
| 144 | +[32m+[m[32m where: { id: { in: body.linkIds }, userId },[m |
| 145 | +[32m+[m[32m select: { id: true },[m |
| 146 | +[32m+[m[32m });[m |
| 147 | +[32m+[m |
| 148 | +[32m+[m[32m if (ownedLinks.length !== body.linkIds.length) {[m |
| 149 | +[32m+[m[32m throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });[m |
| 150 | +[32m+[m[32m }[m |
| 151 | + }[m |
| 152 | + [m |
| 153 | +[31m- const linkIds = body.linkIds[m |
| 154 | +[32m+[m[32m const linkIds = body.linkIds;[m |
| 155 | + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {[m |
| 156 | +[31m- await tx.cardLink.deleteMany({ where: { cardId: id } })[m |
| 157 | +[32m+[m[32m await tx.cardLink.deleteMany({ where: { cardId: id } });[m |
| 158 | + if (linkIds.length > 0) {[m |
| 159 | +[31m- await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) })[m |
| 160 | +[32m+[m[32m await tx.cardLink.createMany({[m |
| 161 | +[32m+[m[32m data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })),[m |
| 162 | +[32m+[m[32m });[m |
| 163 | + }[m |
| 164 | +[31m- })[m |
| 165 | +[32m+[m[32m });[m |
| 166 | + }[m |
| 167 | + [m |
| 168 | +[31m- const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })[m |
| 169 | +[31m- return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) }[m |
| 170 | +[32m+[m[32m const updated = (await app.prisma.card.findUnique({[m |
| 171 | +[32m+[m[32m where: { id },[m |
| 172 | +[32m+[m[32m include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },[m |
| 173 | +[32m+[m[32m })) as unknown as RawCard | null;[m |
| 174 | +[32m+[m |
| 175 | +[32m+[m[32m if (!updated) {[m |
| 176 | +[32m+[m[32m return null;[m |
| 177 | +[32m+[m[32m }[m |
| 178 | +[32m+[m |
| 179 | +[32m+[m[32m return mapCard(updated);[m |
| 180 | + }[m |
| 181 | + [m |
| 182 | +[31m-export async function deleteCard(app: FastifyInstance, userId: string, id: string) {[m |
| 183 | +[32m+[m[32mexport async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise<null> {[m |
| 184 | + return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {[m |
| 185 | +[31m- const existing = await tx.card.findFirst({ where: { id, userId } })[m |
| 186 | +[31m- if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })[m |
| 187 | +[32m+[m[32m const existing = await tx.card.findFirst({ where: { id, userId } });[m |
| 188 | +[32m+[m[32m if (!existing) {[m |
| 189 | +[32m+[m[32m return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' });[m |
| 190 | +[32m+[m[32m }[m |
| 191 | + [m |
| 192 | +[31m- const userCardCount = await tx.card.count({ where: { userId } })[m |
| 193 | +[31m- if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })[m |
| 194 | +[32m+[m[32m const userCardCount = await tx.card.count({ where: { userId } });[m |
| 195 | +[32m+[m[32m if (userCardCount <= 1) {[m |
| 196 | +[32m+[m[32m return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' });[m |
| 197 | +[32m+[m[32m }[m |
| 198 | + [m |
| 199 | + if (existing.isDefault) {[m |
| 200 | +[31m- const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } })[m |
| 201 | +[32m+[m[32m const oldestRemainingCard = await tx.card.findFirst({[m |
| 202 | +[32m+[m[32m where: { userId, id: { not: id } },[m |
| 203 | +[32m+[m[32m orderBy: { createdAt: 'asc' },[m |
| 204 | +[32m+[m[32m });[m |
| 205 | +[32m+[m |
| 206 | + if (oldestRemainingCard) {[m |
| 207 | +[31m- await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } })[m |
| 208 | +[32m+[m[32m await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });[m |
| 209 | + }[m |
| 210 | + }[m |
| 211 | + [m |
| 212 | +[31m- await tx.card.delete({ where: { id } })[m |
| 213 | +[31m- return null[m |
| 214 | +[31m- })[m |
| 215 | +[32m+[m[32m await tx.card.delete({ where: { id } });[m |
| 216 | +[32m+[m[32m return null;[m |
| 217 | +[32m+[m[32m });[m |
| 218 | + }[m |
| 219 | + [m |
| 220 | +[31m-export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) {[m |
| 221 | +[31m- const existing = await app.prisma.card.findFirst({ where: { id, userId } })[m |
| 222 | +[31m- if (!existing) return null[m |
| 223 | +[32m+[m[32mexport async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> {[m |
| 224 | +[32m+[m[32m const existing = await app.prisma.card.findFirst({ where: { id, userId } });[m |
| 225 | +[32m+[m[32m if (!existing) {[m |
| 226 | +[32m+[m[32m return null;[m |
| 227 | +[32m+[m[32m }[m |
| 228 | + [m |
| 229 | + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {[m |
| 230 | +[31m- await tx.card.updateMany({ where: { userId }, data: { isDefault: false } })[m |
| 231 | +[31m- await tx.card.update({ where: { id }, data: { isDefault: true } })[m |
| 232 | +[31m- })[m |
| 233 | +[32m+[m[32m await tx.card.updateMany({ where: { userId }, data: { isDefault: false } });[m |
| 234 | +[32m+[m[32m await tx.card.update({ where: { id }, data: { isDefault: true } });[m |
| 235 | +[32m+[m[32m });[m |
| 236 | + [m |
| 237 | +[31m- return { message: 'Default card updated' }[m |
| 238 | +[32m+[m[32m return { message: 'Default card updated' };[m |
| 239 | + }[m |
0 commit comments