Skip to content

Commit 163ee93

Browse files
committed
fix: resolve race conditions in gamification XP/streak calculation (#178)
- stats POST: Wrap XP + streak + badge computation in Firestore runTransaction for atomic read-compute-write. Two concurrent requests can no longer both read stale XP and overwrite each other's increment. - stats GET: Wrap streak update (daysDiff check) in runTransaction so concurrent GET requests from multiple tabs don't double-increment streak. - award-badge: Replace read-check-write pattern with FieldValue.arrayUnion which is inherently idempotent — concurrent badge awards are safe. - daily-quests updateProgress: Wrap quest progress increment in runTransaction to prevent lost increments from rapid task completions. - daily-quests claim: Wrap claim in runTransaction to prevent double-claim. XP award uses FieldValue.increment() for atomic addition. - fix-streak: Wrap conditional streak fix in runTransaction. - Add idempotency keys: Client generates unique key per awardXP call, server stores result in idempotency collection and returns cached result on duplicate requests (network retry protection). - Use FieldValue.arrayUnion for badge updates in stats transaction to prevent duplicate badge entries without manual dedup logic.
1 parent 2c88603 commit 163ee93

5 files changed

Lines changed: 261 additions & 172 deletions

File tree

src/app/api/gamification/award-badge/route.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextResponse } from "next/server";
2-
import { adminDb } from "@/lib/firebase-admin";
2+
import { adminDb, FieldValue } from "@/lib/firebase-admin";
33

4-
// POST - Award a badge to user
4+
// POST - Award a badge to user (idempotent via FieldValue.arrayUnion)
55
export async function POST(request) {
66
try {
77
const { userId, badgeId } = await request.json();
@@ -27,18 +27,14 @@ export async function POST(request) {
2727
return NextResponse.json({ success: true, badges: [badgeId] });
2828
}
2929

30-
const stats = userDoc.data();
31-
const currentBadges = stats.badges || [];
30+
// Use arrayUnion — inherently idempotent, no race condition
31+
await userRef.update({ badges: FieldValue.arrayUnion(badgeId) });
3232

33-
// Don't add duplicate badges
34-
if (currentBadges.includes(badgeId)) {
35-
return NextResponse.json({ success: true, message: "Badge already earned", badges: currentBadges });
36-
}
37-
38-
const newBadges = [...currentBadges, badgeId];
39-
await userRef.update({ badges: newBadges });
33+
// Read back to return current badges
34+
const updatedDoc = await userRef.get();
35+
const badges = updatedDoc.data()?.badges || [];
4036

41-
return NextResponse.json({ success: true, badges: newBadges });
37+
return NextResponse.json({ success: true, badges });
4238
} catch (error) {
4339
console.error("Error awarding badge:", error);
4440
return NextResponse.json({ error: "Failed to award badge" }, { status: 500 });

src/app/api/gamification/daily-quests/route.js

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { adminDb } from "@/lib/firebase-admin";
2+
import { adminDb, FieldValue } from "@/lib/firebase-admin";
33

44
// Daily quest templates - rotates based on day
55
const QUEST_TEMPLATES = [
@@ -136,68 +136,86 @@ export async function POST(request) {
136136
let userProgress = userQuestsDoc.data();
137137

138138
if (action === "updateProgress") {
139-
// Update progress for quests matching the progress type
140-
userProgress.quests = userProgress.quests.map(quest => {
141-
if (quest.type === progressType && !quest.completed) {
142-
const newProgress = Math.min(quest.progress + (progressIncrement || 1), quest.target);
143-
return {
144-
...quest,
145-
progress: newProgress,
146-
completed: newProgress >= quest.target,
147-
};
148-
}
149-
return quest;
139+
// Use a transaction to prevent race conditions on progress updates
140+
const updatedQuests = await adminDb.runTransaction(async (transaction) => {
141+
const doc = await transaction.get(userQuestsRef);
142+
if (!doc.exists) throw new Error("No quests found");
143+
144+
const data = doc.data();
145+
const updatedQuestList = data.quests.map((quest) => {
146+
if (quest.type === progressType && !quest.completed) {
147+
const newProgress = Math.min(
148+
quest.progress + (progressIncrement || 1),
149+
quest.target
150+
);
151+
return {
152+
...quest,
153+
progress: newProgress,
154+
completed: newProgress >= quest.target,
155+
};
156+
}
157+
return quest;
158+
});
159+
160+
transaction.update(userQuestsRef, { quests: updatedQuestList });
161+
return updatedQuestList;
150162
});
151163

152-
await userQuestsRef.update({ quests: userProgress.quests });
153-
154164
return NextResponse.json({
155165
success: true,
156-
quests: userProgress.quests,
166+
quests: updatedQuests,
157167
});
158168
}
159169

160170
if (action === "claim") {
161-
// Find and claim the quest reward
162-
const questIndex = userProgress.quests.findIndex(q => q.id === questId);
171+
// Use a transaction to prevent double-claim race conditions
172+
const claimResult = await adminDb.runTransaction(async (transaction) => {
173+
const doc = await transaction.get(userQuestsRef);
174+
if (!doc.exists) throw new Error("No quests found");
163175

164-
if (questIndex === -1) {
165-
return NextResponse.json({ error: "Quest not found" }, { status: 404 });
166-
}
176+
const data = doc.data();
177+
const questIndex = data.quests.findIndex((q) => q.id === questId);
167178

168-
const quest = userProgress.quests[questIndex];
179+
if (questIndex === -1) throw new Error("Quest not found");
169180

170-
if (!quest.completed) {
171-
return NextResponse.json({ error: "Quest not completed" }, { status: 400 });
172-
}
181+
const quest = data.quests[questIndex];
173182

174-
if (quest.claimed) {
175-
return NextResponse.json({ error: "Already claimed" }, { status: 400 });
176-
}
183+
if (!quest.completed) throw new Error("Quest not completed");
184+
if (quest.claimed) throw new Error("Already claimed");
177185

178-
// Mark as claimed
179-
userProgress.quests[questIndex].claimed = true;
180-
userProgress.totalXPEarned = (userProgress.totalXPEarned || 0) + quest.xpReward;
186+
// Mark as claimed within transaction
187+
data.quests[questIndex].claimed = true;
188+
const newTotalXP = (data.totalXPEarned || 0) + quest.xpReward;
181189

182-
await userQuestsRef.update({
183-
quests: userProgress.quests,
184-
totalXPEarned: userProgress.totalXPEarned,
185-
});
190+
transaction.update(userQuestsRef, {
191+
quests: data.quests,
192+
totalXPEarned: newTotalXP,
193+
});
186194

187-
// Award XP to user's main stats
188-
const userStatsRef = adminDb.collection("users").doc(userId).collection("gamification").doc("stats");
189-
const statsDoc = await userStatsRef.get();
190-
const currentXP = statsDoc.exists ? (statsDoc.data().xp || 0) : 0;
195+
return { quest: data.quests[questIndex], xpReward: quest.xpReward };
196+
});
191197

192-
await userStatsRef.set({
193-
xp: currentXP + quest.xpReward,
194-
lastUpdated: new Date().toISOString(),
195-
}, { merge: true });
198+
// Award XP to user's main stats using FieldValue.increment (atomic)
199+
const userStatsRef = adminDb
200+
.collection("users")
201+
.doc(userId)
202+
.collection("gamification")
203+
.doc("stats");
204+
205+
await userStatsRef.set(
206+
{
207+
xp: FieldValue.increment(claimResult.xpReward),
208+
lastUpdated: new Date().toISOString(),
209+
},
210+
{ merge: true }
211+
);
196212

197213
return NextResponse.json({
198214
success: true,
199-
xpAwarded: quest.xpReward,
200-
quest: userProgress.quests[questIndex],
215+
xpAwarded: claimResult.xpReward,
216+
quest: claimResult.quest,
217+
});
218+
}
201219
});
202220
}
203221

src/app/api/gamification/fix-streak/route.js

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,45 @@ export async function POST(request) {
1010
}
1111

1212
const userRef = adminDb.collection("gamification").doc(userId);
13-
const userDoc = await userRef.get();
1413

15-
if (!userDoc.exists) {
16-
return NextResponse.json({ error: "User not found" }, { status: 404 });
17-
}
14+
// Use a transaction to prevent race conditions on streak fix
15+
const result = await adminDb.runTransaction(async (transaction) => {
16+
const userDoc = await transaction.get(userRef);
1817

19-
const stats = userDoc.data();
18+
if (!userDoc.exists) {
19+
throw new Error("User not found");
20+
}
2021

21-
// If streak is 0, set it to 1 (they're using it today)
22-
if (stats.streak === 0) {
23-
await userRef.update({
24-
streak: 1,
25-
lastActive: new Date().toISOString(),
26-
});
22+
const stats = userDoc.data();
23+
24+
if (stats.streak === 0) {
25+
transaction.update(userRef, {
26+
streak: 1,
27+
lastActive: new Date().toISOString(),
28+
});
29+
return { fixed: true, newStreak: 1 };
30+
}
2731

32+
return { fixed: false, currentStreak: stats.streak };
33+
});
34+
35+
if (result.fixed) {
2836
return NextResponse.json({
2937
success: true,
3038
message: "Streak fixed to 1",
31-
newStreak: 1,
39+
newStreak: result.newStreak,
3240
});
3341
}
3442

3543
return NextResponse.json({
3644
success: true,
3745
message: "Streak already set",
38-
currentStreak: stats.streak,
46+
currentStreak: result.currentStreak,
3947
});
4048
} catch (error) {
49+
if (error.message === "User not found") {
50+
return NextResponse.json({ error: "User not found" }, { status: 404 });
51+
}
4152
console.error("Error fixing streak:", error);
4253
return NextResponse.json({ error: "Failed to fix streak" }, { status: 500 });
4354
}

0 commit comments

Comments
 (0)