fix(gamification): normalize attendanceDate so awardXp accepts string or Date#3871
Open
hariom888 wants to merge 1 commit into
Open
Conversation
… or Date awardXp() in lib/gamification-service.js assumed metadata.attendanceDate was always a Date instance, calling .getFullYear() (via processStreak -> daysBetween) and .toISOString() directly on it. But the offline-sync path (app/api/attendance/sync/route.js) always passes it as a \'YYYY-MM-DD\' string, produced by getLocalDateKey(). This was masked on a student's first-ever attendance award, since processStreak short-circuits when there's no prior lastAttendanceDate. But the second offline-synced award (and every one after) threw TypeError: ... is not a function, which the queue worker caught, retried 3 times, and ultimately failed — meaning offline-synced attendance was awarding zero XP after a student's first record, not double XP. Normalize metadata.attendanceDate once into both a string key (effectiveDate, used for the idempotency lookups and persisted date fields) and a Date instance (attendanceInstant, used for processStreak()/daysBetween() and any .toISOString() call), instead of using the raw value inconsistently in both roles. Found while writing the idempotency regression test requested in Premshaw23#3806 — that issue's described duplicate-call code path doesn't exist in the current codebase (the sync route only enqueues a job; awardXp already has a solid idempotency guard), but exercising that guard with realistic offline-sync inputs surfaced this separate, currently-live bug in the same function.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
#3806 reported a duplicate-XP-award race between a synchronous
awardXp()call and a queuedAWARD_GAMIFICATION_XPjob inapp/api/attendance/sync/route.js. That code path doesn't exist in the current repo — the route only callsenqueue(), andawardXpalready enforces idempotency per(firebaseUid, "attendance_marked", date)via anattendanceHistorycheck, a uniquely-indexedxp_awardscollection, and a distributed lock (withLock).While writing the idempotency regression test #3806 asked for, against realistic offline-sync inputs, I found a different, real bug living in that same idempotency code.
Problem
app/api/attendance/sync/route.jspassesmetadata.attendanceDateas a"YYYY-MM-DD"string (fromgetLocalDateKey()). Butlib/gamification-service.jsassumed it was always aDateinstance:processStreak(student, attendanceInstant)→daysBetween()callsattendanceInstant.getFullYear()directly.updateFields.lastAttendanceDate = (metadata.attendanceDate || now).toISOString()calls.toISOString()directly.Both throw
TypeError: ... is not a functionon a plain string. This was masked on a student's first attendance award, sinceprocessStreakshort-circuits when there's no priorlastAttendanceDate— but every subsequent offline-synced award threw, got caught by the queue worker's error handling, retried 3 times (all failing identically), and ended up in the failed-job queue. Net effect: offline-synced attendance was awarding zero XP after a student's first record — the opposite of double-awarding.The online path is unaffected since it doesn't pass
attendanceDateat all.Fix
Normalize
metadata.attendanceDateonce, right where it's first used, into:effectiveDate— a plain string, used for the idempotency lookups (attendanceHistorycomparison,xp_awards.date) and persisteddatefields, which were always plain strings elsewhere anyway.attendanceInstant— a realDateinstance, used forprocessStreak()/daysBetween()and the.toISOString()call.Both a string and a
Dateinstance are accepted; if neither is provided, both fall back tonow/today's date string exactly as before.Tests
lib/__tests__/gamificationXpIdempotency.test.js(5 tests):xp_awardswritten once.withLockmock that faithfully reproduces the real Redis-backed mutex) → one award, one no-op.attendanceDateon a student's second (consecutive-day) award no longer throws, and the streak correctly continues.Dateinstance is still accepted, for backward compatibility.The pre-existing
lib/__tests__/gamification-service.test.js(which exercisesattendanceDateas aDate) still passes unchanged.Closes #3806