Skip to content

fix(gamification): normalize attendanceDate so awardXp accepts string or Date#3871

Open
hariom888 wants to merge 1 commit into
Premshaw23:masterfrom
hariom888:fix/gamification-attendance-date-type-mismatch
Open

fix(gamification): normalize attendanceDate so awardXp accepts string or Date#3871
hariom888 wants to merge 1 commit into
Premshaw23:masterfrom
hariom888:fix/gamification-attendance-date-type-mismatch

Conversation

@hariom888

Copy link
Copy Markdown

Context

#3806 reported a duplicate-XP-award race between a synchronous awardXp() call and a queued AWARD_GAMIFICATION_XP job in app/api/attendance/sync/route.js. That code path doesn't exist in the current repo — the route only calls enqueue(), and awardXp already enforces idempotency per (firebaseUid, "attendance_marked", date) via an attendanceHistory check, a uniquely-indexed xp_awards collection, 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.js passes metadata.attendanceDate as a "YYYY-MM-DD" string (from getLocalDateKey()). But lib/gamification-service.js assumed it was always a Date instance:

  • processStreak(student, attendanceInstant)daysBetween() calls attendanceInstant.getFullYear() directly.
  • updateFields.lastAttendanceDate = (metadata.attendanceDate || now).toISOString() calls .toISOString() directly.

Both throw TypeError: ... is not a function on a plain string. This was masked on a student's first attendance award, since processStreak short-circuits when there's no prior lastAttendanceDate — 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 attendanceDate at all.

Fix

Normalize metadata.attendanceDate once, right where it's first used, into:

  • effectiveDate — a plain string, used for the idempotency lookups (attendanceHistory comparison, xp_awards.date) and persisted date fields, which were always plain strings elsewhere anyway.
  • attendanceInstant — a real Date instance, used for processStreak()/daysBetween() and the .toISOString() call.

Both a string and a Date instance are accepted; if neither is provided, both fall back to now/today's date string exactly as before.

Tests

lib/__tests__/gamificationXpIdempotency.test.js (5 tests):

  • Sequential duplicate calls → one award, one no-op, xp_awards written once.
  • Concurrent duplicate calls (serialized via a withLock mock that faithfully reproduces the real Redis-backed mutex) → one award, one no-op.
  • Different dates are not treated as duplicates.
  • Regression test for this fix: a string attendanceDate on a student's second (consecutive-day) award no longer throws, and the streak correctly continues.
  • A Date instance is still accepted, for backward compatibility.

The pre-existing lib/__tests__/gamification-service.test.js (which exercises attendanceDate as a Date) still passes unchanged.

Closes #3806

… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] : Offline attendance sync double-awards XP via both direct call and queued job for the same record

1 participant