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
108 changes: 108 additions & 0 deletions src/main/java/com/iemr/flw/controller/BadgeController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.iemr.flw.controller;

import com.iemr.flw.dto.iemr.BadgeEarnedPushDTO;
import com.iemr.flw.service.BadgeService;
import com.iemr.flw.utils.JwtUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
* Badges API consumed by the SAKHI app's BadgeSyncWorker:
* GET /badges/config -> { "config": { key: value } }
* GET /badges/freezes -> { "freezes": [ { badgeId, startDate, endDate } ] }
* GET /badges/earned -> { "earned": [ { badgeId, level, earnedAt } ] }
* POST /badges/earned <- { userId, badges: [ { badgeId, level, earnedAt } ] }
* The ASHA is always identified from the JWT, never from the payload.
*/
@RestController
@RequestMapping(value = "/badges", produces = "application/json")
public class BadgeController {

private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
private final BadgeService badgeService;
private final JwtUtil jwtUtil;

public BadgeController(BadgeService badgeService, JwtUtil jwtUtil) {
this.badgeService = badgeService;
this.jwtUtil = jwtUtil;
}

@GetMapping("/config")
public ResponseEntity<Map<String, Object>> getConfig() {
Map<String, Object> response = ok();
try {
response.put("config", badgeService.getConfig());
return ResponseEntity.ok(response);
} catch (Exception e) {
return error(e);
}
}

@GetMapping("/freezes")
public ResponseEntity<Map<String, Object>> getFreezes(
@RequestHeader(value = "JwtToken") String jwtToken) {
Map<String, Object> response = ok();
try {
Integer userId = jwtUtil.extractUserId(jwtToken);
response.put("freezes", badgeService.getFreezes(userId));
return ResponseEntity.ok(response);
} catch (Exception e) {
return error(e);
}
}

@GetMapping("/earned")
public ResponseEntity<Map<String, Object>> getEarned(
@RequestHeader(value = "JwtToken") String jwtToken) {
Map<String, Object> response = ok();
try {
Integer userId = jwtUtil.extractUserId(jwtToken);
response.put("earned", badgeService.getEarned(userId));
return ResponseEntity.ok(response);
} catch (Exception e) {
return error(e);
}
}

@PostMapping(value = "/earned", consumes = "application/json")
public ResponseEntity<Map<String, Object>> postEarned(
@RequestHeader(value = "JwtToken") String jwtToken,
@RequestBody BadgeEarnedPushDTO body) {
Map<String, Object> response = ok();
try {
Integer userId = jwtUtil.extractUserId(jwtToken);
int inserted = badgeService.saveEarned(userId, body == null ? null : body.getBadges());
response.put("inserted", inserted);
return ResponseEntity.ok(response);
} catch (Exception e) {
return error(e);
}
}

private Map<String, Object> ok() {
Map<String, Object> response = new HashMap<>();
response.put("statusCode", 200);
response.put("status", "Success");
return response;
}

private ResponseEntity<Map<String, Object>> error(Exception e) {
logger.error("Badges API error:", e);
Map<String, Object> response = new HashMap<>();
response.put("statusCode", 500);
response.put("status", "Error");
response.put("errorMessage", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/iemr/flw/domain/iemr/BadgeConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.iemr.flw.domain.iemr;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Badge tuning as key/value rows (Badge LLD §5.2). Overrides the app's
* compiled defaults without a release, e.g. "milestones.steady_syncer" -> "2,4,6,8".
*/
@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "badge_config", schema = "db_iemr")
public class BadgeConfig {

@Id
@Column(name = "config_key", length = 100)
private String key;

@Column(name = "config_value", length = 500, nullable = false)
private String value;
}
62 changes: 62 additions & 0 deletions src/main/java/com/iemr/flw/domain/iemr/BadgeEarned.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.iemr.flw.domain.iemr;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Data;

/**
* Append-only badge award log (Badge LLD §4.1, "once earned, never revoked").
* The unique key mirrors the device's local constraint so re-uploads after a
* reinstall or a retried sync are idempotent.
*
* The key includes award_key because (user, badge, level) is not unique for every badge.
* Quarterly badges are re-earned each quarter at the same level, and per-case badges are
* earned once per beneficiary, so without it the second and later awards collapse into the
* first on upload and cannot be told apart on restore.
*/
@Entity
@Data
@Table(name = "badge_earned", schema = "db_iemr",
uniqueConstraints = @UniqueConstraint(
name = "uk_badge_earned_user_badge_level_key",
columnNames = {"user_id", "badge_id", "level", "award_key"}))
public class BadgeEarned {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id", nullable = false)
private Integer userId;

@Column(name = "badge_id", length = 50, nullable = false)
private String badgeId;

@Column(name = "level", nullable = false)
private Integer level;

/**
* What makes two awards of the same badge and level different: a quarter key such as
* "2026-Q3", or an opaque digest for per-case badges. Empty for streak and cumulative
* badges, which are earned once per level.
*
* Never a beneficiary identifier. The device hashes those before they are sent, so this
* column can separate two awards without the server learning who either was about
* (Badge LLD §4).
*/
@Column(name = "award_key", length = 64, nullable = false)
private String awardKey = "";

/** Epoch millis when the device awarded it. */
@Column(name = "earned_at", nullable = false)
private Long earnedAt;

/** Epoch millis when the server first received it. */
@Column(name = "received_at", nullable = false)
private Long receivedAt;
}
37 changes: 37 additions & 0 deletions src/main/java/com/iemr/flw/domain/iemr/BadgeStreakFreeze.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.iemr.flw.domain.iemr;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;

/**
* Streak-freeze window (Badge LLD §3.3): illness / alternate duty. A null userId
* applies to every ASHA; a null or empty badgeId applies to every streak badge.
*/
@Entity
@Data
@Table(name = "badge_streak_freeze", schema = "db_iemr")
public class BadgeStreakFreeze {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id")
private Integer userId;

@Column(name = "badge_id", length = 50)
private String badgeId;

/** Epoch millis, inclusive. */
@Column(name = "start_date", nullable = false)
private Long startDate;

/** Epoch millis, inclusive. */
@Column(name = "end_date", nullable = false)
private Long endDate;
}
22 changes: 22 additions & 0 deletions src/main/java/com/iemr/flw/dto/iemr/BadgeEarnedDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.iemr.flw.dto.iemr;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/** Wire shape shared with the app's BadgeEarnedDTO (Moshi). */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BadgeEarnedDTO {
private String badgeId;
private Integer level;
private Long earnedAt;

/**
* Stable discriminator for badges that can be earned more than once at the same level.
* A quarter key, or an opaque digest for per-case awards; empty otherwise. Never a
* beneficiary identifier — see {@link com.iemr.flw.domain.iemr.BadgeEarned#getAwardKey()}.
*/
private String awardKey;
}
15 changes: 15 additions & 0 deletions src/main/java/com/iemr/flw/dto/iemr/BadgeEarnedPushDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.iemr.flw.dto.iemr;

import lombok.Data;

import java.util.List;

/**
* POST /badges/earned body. The userId field is accepted for wire compatibility
* but never trusted: the owner is always taken from the JWT.
*/
@Data
public class BadgeEarnedPushDTO {
private Integer userId;
private List<BadgeEarnedDTO> badges;
}
9 changes: 9 additions & 0 deletions src/main/java/com/iemr/flw/repo/iemr/BadgeConfigRepo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.iemr.flw.repo.iemr;

import com.iemr.flw.domain.iemr.BadgeConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BadgeConfigRepo extends JpaRepository<BadgeConfig, String> {
}
15 changes: 15 additions & 0 deletions src/main/java/com/iemr/flw/repo/iemr/BadgeEarnedRepo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.iemr.flw.repo.iemr;

import com.iemr.flw.domain.iemr.BadgeEarned;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface BadgeEarnedRepo extends JpaRepository<BadgeEarned, Long> {
List<BadgeEarned> findByUserIdOrderByEarnedAtAsc(Integer userId);

boolean existsByUserIdAndBadgeIdAndLevelAndAwardKey(
Integer userId, String badgeId, Integer level, String awardKey);
}
13 changes: 13 additions & 0 deletions src/main/java/com/iemr/flw/repo/iemr/BadgeStreakFreezeRepo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.iemr.flw.repo.iemr;

import com.iemr.flw.domain.iemr.BadgeStreakFreeze;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface BadgeStreakFreezeRepo extends JpaRepository<BadgeStreakFreeze, Long> {
/** Global windows (user_id IS NULL) plus this ASHA's own. */
List<BadgeStreakFreeze> findByUserIdIsNullOrUserId(Integer userId);
}
49 changes: 49 additions & 0 deletions src/main/java/com/iemr/flw/seeder/BadgeConfigSeeder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.iemr.flw.seeder;

import com.iemr.flw.domain.iemr.BadgeConfig;
import com.iemr.flw.repo.iemr.BadgeConfigRepo;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.util.List;

/**
* Seeds the default badge tuning once (table empty). Values mirror the app's
* compiled defaults so a fresh server changes nothing until someone edits a row.
*/
@Component
public class BadgeConfigSeeder {

private static final Logger log = LoggerFactory.getLogger(BadgeConfigSeeder.class);

private final BadgeConfigRepo repo;

public BadgeConfigSeeder(BadgeConfigRepo repo) {
this.repo = repo;
}

@PostConstruct
public void seed() {
try {
if (repo.count() > 0) return;
repo.saveAll(List.of(
new BadgeConfig("feature_enabled", "true"),
new BadgeConfig("copy_version", "1"),
new BadgeConfig("milestones.steady_syncer", "2,4,6,8"),
new BadgeConfig("grace.steady_syncer", "1"),
new BadgeConfig("milestones.timely_reporter", "1,3,6,12"),
new BadgeConfig("milestones.complete_worker", "3"),
new BadgeConfig("milestones.community_voice", "2"),
new BadgeConfig("milestones.maternal_journey", "1,5,15,30"),
new BadgeConfig("milestones.child_fully_protected", "3,10,25,60"),
new BadgeConfig("milestones.digital_identity", "10,25,75,150"),
new BadgeConfig("enabled.critical_referral", "false")
));
log.info("Badge config defaults seeded");
} catch (Exception e) {
log.warn("Badge config seeding skipped: {}", e.getMessage());
}
}
}
Loading
Loading