Skip to content

Commit ab0ab6a

Browse files
authored
fix: wrap card delete in transaction with count guard and default promotion (#372)
Closes #328 - Enforce minimum-one-card invariant: reject DELETE with 400 when the user has only one card remaining - Promote the next oldest card to default atomically if the deleted card was the default - Wrap count check, optional default promotion, and delete in a single Prisma transaction so concurrent requests cannot race past the guard
1 parent 94593d6 commit ab0ab6a

1 file changed

Lines changed: 28 additions & 28 deletions

File tree

apps/backend/src/routes/cards.ts

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -230,40 +230,40 @@ export async function cardRoutes(app: FastifyInstance): Promise<void> {
230230
const { id } = request.params;
231231

232232
try {
233-
const existing = await app.prisma.card.findFirst({
234-
where: { id, userId },
235-
});
236-
237-
if (!existing) {
238-
reply.status(404).send({ error: 'Card not found' });
239-
return;
240-
}
233+
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
234+
const existing = await tx.card.findFirst({ where: { id, userId } });
241235

242-
// Prevent deleting the last card — every user must retain at least one.
243-
const userCardCount = await app.prisma.card.count({ where: { userId } });
244-
if (userCardCount <= 1) {
245-
reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' });
246-
return;
247-
}
236+
if (!existing) {
237+
reply.status(404).send({ error: 'Card not found' });
238+
return;
239+
}
248240

249-
// If the card being deleted is the default, promote the next-oldest card
250-
// before deletion so the user always has an active default.
251-
if (existing.isDefault) {
252-
const oldestRemainingCard = await app.prisma.card.findFirst({
253-
where: { userId, id: { not: id } },
254-
orderBy: { createdAt: 'asc' },
255-
});
241+
// Prevent deleting the last card — every user must retain at least one.
242+
const userCardCount = await tx.card.count({ where: { userId } });
243+
if (userCardCount <= 1) {
244+
reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' });
245+
return;
246+
}
256247

257-
if (oldestRemainingCard) {
258-
await app.prisma.card.update({
259-
where: { id: oldestRemainingCard.id },
260-
data: { isDefault: true },
248+
// If the card being deleted is the default, promote the next-oldest card
249+
// before deletion so the user always has an active default.
250+
if (existing.isDefault) {
251+
const oldestRemainingCard = await tx.card.findFirst({
252+
where: { userId, id: { not: id } },
253+
orderBy: { createdAt: 'asc' },
261254
});
255+
256+
if (oldestRemainingCard) {
257+
await tx.card.update({
258+
where: { id: oldestRemainingCard.id },
259+
data: { isDefault: true },
260+
});
261+
}
262262
}
263-
}
264263

265-
await app.prisma.card.delete({ where: { id } });
266-
reply.status(204).send();
264+
await tx.card.delete({ where: { id } });
265+
reply.status(204).send();
266+
});
267267
} catch (error) {
268268
return handleDbError(error, request, reply);
269269
}

0 commit comments

Comments
 (0)