Summary
DELETE /api/cards/:id performs three separate database operations in sequence without wrapping them in a transaction:
prisma.card.count() to enforce the minimum-one-card invariant
prisma.card.update() to promote the next oldest card as default
prisma.card.delete() to remove the target card
Because these are three independent Prisma calls with no transaction boundary, concurrent DELETE requests can race past the count guard and violate the invariant.
Affected File
apps/backend/src/routes/cards.ts - DELETE /:id handler (lines 195-237)
// Step 1 - count check (no lock held)
const userCardCount = await app.prisma.card.count({ where: { userId } });
if (userCardCount <= 1) {
reply.status(400).send({ error: 'Cannot delete the last remaining card...' });
return;
}
// Step 2 - conditional default promotion (separate round-trip)
if (existing.isDefault) {
const oldestRemainingCard = await app.prisma.card.findFirst({ ... });
if (oldestRemainingCard) {
await app.prisma.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
}
}
// Step 3 - delete (another separate round-trip)
await app.prisma.card.delete({ where: { id } });
Race Condition Scenario
Suppose a user has exactly two cards: Card A (default) and Card B.
- Request 1 deletes Card A:
count() returns 2, guard passes.
- Request 2 deletes Card B:
count() also returns 2 (Card A not deleted yet), guard passes.
- Request 1 promotes Card B as default and deletes Card A. User has Card B.
- Request 2 proceeds to delete Card B. User now has zero cards.
The same window exists between step 2 (promotion) and step 3 (delete) for the default-card path: another request can see the partially-promoted state before the delete commits.
Impact
- Data integrity violation: a user can end up with zero cards, breaking the minimum-one-card invariant the guard is meant to enforce.
- Corrupted default state: a card can be promoted to default and then immediately deleted within the same window.
Suggested Fix
Wrap the entire count check, promotion, and delete inside a prisma.$transaction:
await app.prisma.$transaction(async (tx) => {
const userCardCount = await tx.card.count({ where: { userId } });
if (userCardCount <= 1) {
return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' });
}
if (existing.isDefault) {
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.delete({ where: { id } });
});
This makes the entire sequence atomic: the count check, the promotion, and the delete either all succeed together or all roll back, with no window for concurrent interleaving.
Environment
apps/backend/src/routes/cards.ts
- Prisma ORM with PostgreSQL
- Fastify backend
Summary
DELETE /api/cards/:idperforms three separate database operations in sequence without wrapping them in a transaction:prisma.card.count()to enforce the minimum-one-card invariantprisma.card.update()to promote the next oldest card as defaultprisma.card.delete()to remove the target cardBecause these are three independent Prisma calls with no transaction boundary, concurrent DELETE requests can race past the count guard and violate the invariant.
Affected File
apps/backend/src/routes/cards.ts-DELETE /:idhandler (lines 195-237)Race Condition Scenario
Suppose a user has exactly two cards: Card A (default) and Card B.
count()returns 2, guard passes.count()also returns 2 (Card A not deleted yet), guard passes.The same window exists between step 2 (promotion) and step 3 (delete) for the default-card path: another request can see the partially-promoted state before the delete commits.
Impact
Suggested Fix
Wrap the entire count check, promotion, and delete inside a
prisma.$transaction:This makes the entire sequence atomic: the count check, the promotion, and the delete either all succeed together or all roll back, with no window for concurrent interleaving.
Environment
apps/backend/src/routes/cards.ts