Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions gamification-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
kotlin("jvm")
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
testImplementation(kotlin("test"))
}

tasks.test {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.piramal.gamification.core

/**
* A gamification event triggered by a health-worker action.
*
* Each event corresponds to a concrete workflow step (e.g., household
* registration, ANC visit, immunization) and carries the points earned
* as well as sync metadata for offline-first operation.
*
* @property eventId Unique event identifier (used for idempotent dedup).
* @property workerId The health worker who performed the action.
* @property actionType The type of health action (e.g., "registration",
* "anc_visit", "immunization").
* @property points Points earned for this event.
* @property localDate ISO date string for daily-cap calculation.
* @property syncStatus Tracks offline-sync lifecycle: "pending" | "synced".
* @property rewardId Stable reward identifier across sync retries.
*/
data class GamificationEvent(
val eventId: String,
val workerId: String,
val actionType: String,
val points: Int,
val localDate: String,
val syncStatus: String = "pending",
val rewardId: String? = null,
)
Comment on lines +19 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject negative event points at model boundary.

points is trusted input and currently can be negative (Line 23), which allows decreasing totalPoints in the engine.

Proposed fix
 data class GamificationEvent(
     val eventId: String,
     val workerId: String,
     val actionType: String,
     val points: Int,
     val localDate: String,
     val syncStatus: String = "pending",
     val rewardId: String? = null,
-)
+) {
+    init {
+        require(points >= 0) { "points must be >= 0" }
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationEvent.kt`
around lines 19 - 27, The GamificationEvent data class currently allows negative
points via the points property; add a validation in the class to reject
negatives by adding a constructor/init check (e.g., use require(points >= 0) {
"points must be non-negative" }) so any attempt to instantiate GamificationEvent
with negative points throws an IllegalArgumentException; update any
callers/tests to expect this behavior.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.piramal.gamification.core

/**
* Core rule engine for the FLW gamification module.
*
* Processes [GamificationEvent] instances against a worker's current
* [WorkerProgress] and returns the updated progress. All state is
* immutable — the engine returns a new copy on each invocation.
*
* Key behaviours:
* - **Idempotency**: duplicate event IDs are silently accepted without
* double-counting points.
* - **Daily cap**: points exceeding [RewardRule.DAILY_CAP_DEFAULT] for
* a single day are discarded.
* - **Offline safety**: events with any [syncStatus] are tracked in
* [WorkerProgress.acceptedEventIds] so retries after connectivity
* restoration do not double-count.
*/
class GamificationRuleEngine(
private val rules: Map<String, RewardRule> = emptyMap(),
) {
/**
* Apply an event and return the resulting progress.
*
* @param event The incoming gamification event.
* @param progress The worker's current progress snapshot.
* @return Updated progress with points applied (subject to cap and dedup).
*/
fun apply(event: GamificationEvent, progress: WorkerProgress): WorkerProgress {
// Idempotency: skip point award for already-processed events.
if (event.eventId in progress.acceptedEventIds) {
return progress.copy(
acceptedEventIds = progress.acceptedEventIds + event.eventId,
)
}

val currentDayPoints = progress.dailyPoints[event.localDate] ?: 0
val cap = rules[event.actionType]?.dailyCap ?: RewardRule.DAILY_CAP_DEFAULT

// If the daily cap is already reached, record the event but award no points.
if (currentDayPoints >= cap) {
return progress.copy(
acceptedEventIds = progress.acceptedEventIds + event.eventId,
dailyPoints = progress.dailyPoints,
)
}

// Award points up to the remaining daily capacity.
val available = cap - currentDayPoints
val pointsToAdd = minOf(event.points, available)
Comment on lines +48 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use rule-configured points instead of trusting event payload points.

Line 50 awards from event.points, so rule-defined pointsAwarded is effectively ignored. This allows inconsistent or tampered scoring.

Proposed fix
-        val available = cap - currentDayPoints
-        val pointsToAdd = minOf(event.points, available)
+        val configuredPoints = rules[event.actionType]?.pointsAwarded ?: event.points
+        val available = cap - currentDayPoints
+        val pointsToAdd = minOf(configuredPoints, available)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@gamification-core/src/main/kotlin/org/piramal/gamification/core/GamificationRuleEngine.kt`
around lines 48 - 50, The current logic in GamificationRuleEngine.kt computes
pointsToAdd using event.points which ignores the rule's configured pointsAwarded
and can be tampered; change the calculation to use the rule-configured value
(e.g., pointsAwarded from the rule/action object) instead of event.points while
still respecting the daily cap (available = cap - currentDayPoints) and taking
minOf(rule.pointsAwarded, available) so the rule-defined award is applied but
not exceeding capacity.


return progress.copy(
acceptedEventIds = progress.acceptedEventIds + event.eventId,
dailyPoints = progress.dailyPoints + (event.localDate to currentDayPoints + pointsToAdd),
totalPoints = progress.totalPoints + pointsToAdd,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.piramal.gamification.core

/**
* Configuration rule mapping a health-action type to its gamification parameters.
*
* @property actionType The health action this rule applies to.
* @property pointsAwarded Points granted when this action is recorded.
* @property dailyCap Maximum points per day from this action type.
* Applies globally across all action types unless overridden.
* @property badgeId Optional badge awarded on first completion.
*/
data class RewardRule(
val actionType: String,
val pointsAwarded: Int,
val dailyCap: Int = DAILY_CAP_DEFAULT,
val badgeId: String? = null,
) {
companion object {
const val DAILY_CAP_DEFAULT = 100
}
Comment on lines +12 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add invariants for non-negative rule values.

pointsAwarded and dailyCap are unconstrained. A negative cap/points can produce negative awards downstream (Line 12), which corrupts totals.

Proposed fix
 data class RewardRule(
     val actionType: String,
     val pointsAwarded: Int,
     val dailyCap: Int = DAILY_CAP_DEFAULT,
     val badgeId: String? = null,
 ) {
+    init {
+        require(pointsAwarded >= 0) { "pointsAwarded must be >= 0" }
+        require(dailyCap >= 0) { "dailyCap must be >= 0" }
+    }
+
     companion object {
         const val DAILY_CAP_DEFAULT = 100
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@gamification-core/src/main/kotlin/org/piramal/gamification/core/RewardRule.kt`
around lines 12 - 20, RewardRule currently allows negative pointsAwarded and
dailyCap which can corrupt totals; add input invariants in the RewardRule class
by validating pointsAwarded and dailyCap in an init block (e.g., using
require(...) to throw IllegalArgumentException) to ensure both are >= 0, and
verify/adjust the companion constant DAILY_CAP_DEFAULT is non-negative or
document it as such; reference RewardRule, pointsAwarded, dailyCap, and
DAILY_CAP_DEFAULT when making the change.

}

/**
* Aggregated progress snapshot for a single health worker.
*
* @property acceptedEventIds Set of event IDs already processed (dedup).
* @property dailyPoints Accumulated points per ISO date string.
* @property totalPoints Lifetime points across all days.
*/
data class WorkerProgress(
val acceptedEventIds: Set<String> = emptySet(),
val dailyPoints: Map<String, Int> = emptyMap(),
val totalPoints: Int = 0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package org.piramal.gamification.core

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* Unit tests for [GamificationRuleEngine].
*
* Covers:
* - Duplicate event ID handling (dedup)
* - Daily cap enforcement
* - Offline event tracking
* - Stable reward ID across sync retries
*/
class GamificationRuleEngineTest {

private val engine = GamificationRuleEngine()

@Test
fun `duplicate event ID is idempotent`() {
val event = GamificationEvent(
eventId = "evt-001",
workerId = "worker-1",
actionType = "registration",
points = 10,
localDate = "2026-05-07",
)

val p1 = engine.apply(event, WorkerProgress())
val p2 = engine.apply(event, p1) // same event repeated

assertEquals(10, p1.totalPoints, "first application should award points")
assertEquals(p1.totalPoints, p2.totalPoints, "duplicate should not add points")
assertTrue(p2.acceptedEventIds.contains("evt-001"))
}

@Test
fun `daily cap stops excess points`() {
val big = GamificationEvent("evt-001", "w1", "visit", 60, "2026-05-07")
val alsoBig = GamificationEvent("evt-002", "w1", "visit", 60, "2026-05-07")

val p1 = engine.apply(big, WorkerProgress())
val p2 = engine.apply(alsoBig, p1)

assertEquals(100, p2.totalPoints, "total should be capped at 100")
assertEquals(60, p2.dailyPoints["2026-05-07"], "60 of 60 second-event points blocked")
Comment on lines +46 to +47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Daily-cap assertion is internally inconsistent.

After two 60-point events on the same date, asserting totalPoints == 100 means that day's accumulated points must also be 100, not 60 (Line 47).

Proposed fix
-        assertEquals(60, p2.dailyPoints["2026-05-07"], "60 of 60 second-event points blocked")
+        assertEquals(100, p2.dailyPoints["2026-05-07"], "40 of 60 second-event points should be blocked by cap")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assertEquals(100, p2.totalPoints, "total should be capped at 100")
assertEquals(60, p2.dailyPoints["2026-05-07"], "60 of 60 second-event points blocked")
assertEquals(100, p2.totalPoints, "total should be capped at 100")
assertEquals(100, p2.dailyPoints["2026-05-07"], "40 of 60 second-event points should be blocked by cap")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt`
around lines 46 - 47, The test's assertion is inconsistent: after two 60-point
events on the same date the account-level cap makes p2.totalPoints == 100, so
the per-day accumulator for that date must reflect 100, not 60; update the
assertion in GamificationRuleEngineTest (references: p2, totalPoints,
dailyPoints) to expect 100 for the key "2026-05-07" (or otherwise assert that
dailyPoints["2026-05-07"] equals p2.totalPoints) so the daily and total
expectations are consistent.

}

@Test
fun `offline event is tracked before sync`() {
val offline = GamificationEvent(
eventId = "evt-off-1",
workerId = "w1",
actionType = "screening",
points = 20,
localDate = "2026-05-07",
syncStatus = "pending",
)

val result = engine.apply(offline, WorkerProgress())

assertEquals(20, result.totalPoints)
assertTrue(result.acceptedEventIds.contains("evt-off-1"))
}

@Test
fun `synced event retry with same reward ID is stable`() {
val synced = GamificationEvent(
eventId = "evt-sync-1",
workerId = "w1",
actionType = "immunization",
points = 15,
localDate = "2026-05-07",
syncStatus = "synced",
rewardId = "reward-001",
)

val p1 = engine.apply(synced, WorkerProgress())
val p2 = engine.apply(synced, p1)

assertEquals(p1.totalPoints, p2.totalPoints, "retry must not double-count")
}
Comment on lines +68 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test does not actually verify rewardId retry behavior.

It replays the exact same eventId, so pass/fail depends on eventId dedup only. To validate reward-id stability, retry should use a different eventId but the same rewardId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@gamification-core/src/test/kotlin/org/piramal/gamification/core/GamificationRuleEngineTest.kt`
around lines 68 - 83, The test function `synced event retry with same reward ID
is stable` currently reuses the same GamificationEvent (so dedup by eventId
masks rewardId behavior); modify the second invocation to supply a different
eventId but keep the same rewardId to validate reward-id stability—e.g., create
a new GamificationEvent (or copy the original) with a new `eventId` and
identical `rewardId`, then call `engine.apply(newEvent, p1)` and assert
`p1.totalPoints == p2.totalPoints`; update references to `GamificationEvent`,
`engine.apply`, and the test name accordingly.

}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Sakhi"
include ':app'
include ':gamification-core'