From 768b3f783e8e50d5bab36323fdce00981926cc6c Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Tue, 1 Jul 2025 17:26:57 +0530 Subject: [PATCH 01/12] fix: AMM-1677 - rendering only grievances who have consent --- .../com/iemr/common/dto/grivance/GrievanceWorklistDTO.java | 6 ++++-- .../service/grievance/GrievanceHandlingServiceImpl.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/iemr/common/dto/grivance/GrievanceWorklistDTO.java b/src/main/java/com/iemr/common/dto/grivance/GrievanceWorklistDTO.java index b8184162..6364b7f6 100644 --- a/src/main/java/com/iemr/common/dto/grivance/GrievanceWorklistDTO.java +++ b/src/main/java/com/iemr/common/dto/grivance/GrievanceWorklistDTO.java @@ -39,13 +39,14 @@ public class GrievanceWorklistDTO implements Serializable { private String age; private Boolean retryNeeded; private Integer callCounter; - private Timestamp lastCall; + private Timestamp lastCall; + private Boolean beneficiaryConsent; public GrievanceWorklistDTO(String complaintID,Long grievanceId, String subjectOfComplaint, String complaint, Long beneficiaryRegID, Integer providerServiceMapID,String primaryNumber,String severety,String state, Integer userId, Boolean deleted, String createdBy, Timestamp createdDate, Timestamp lastModDate, Boolean isCompleted,String firstName, String lastName, String gender, String district, Long beneficiaryID, String age, - Boolean retryNeeded, Integer callCounter, Timestamp lastCall) { + Boolean retryNeeded, Integer callCounter, Timestamp lastCall, Boolean beneficiaryConsent) { super(); this.complaintID = complaintID; this.grievanceId = grievanceId; @@ -71,6 +72,7 @@ public GrievanceWorklistDTO(String complaintID,Long grievanceId, String subjectO this.retryNeeded = retryNeeded; this.callCounter = callCounter; this.lastCall = lastCall; + this.beneficiaryConsent = beneficiaryConsent; } diff --git a/src/main/java/com/iemr/common/service/grievance/GrievanceHandlingServiceImpl.java b/src/main/java/com/iemr/common/service/grievance/GrievanceHandlingServiceImpl.java index 68e8e76e..ed643417 100644 --- a/src/main/java/com/iemr/common/service/grievance/GrievanceHandlingServiceImpl.java +++ b/src/main/java/com/iemr/common/service/grievance/GrievanceHandlingServiceImpl.java @@ -296,7 +296,7 @@ public List getFormattedGrievanceData(String request) thro // Loop through the worklist data and format the response for (Object[] row : worklistData) { - if (row == null || row.length < 22) + if (row == null || row.length < 24) { logger.warn("invalid row data received"); continue; @@ -334,7 +334,8 @@ public List getFormattedGrievanceData(String request) thro ageFormatted, (Boolean) row[21], // retryNeeded (Integer) row[22], // callCounter - (Timestamp) row[13] //lastCall + (Timestamp) row[13], // lastCall + (Boolean) row[23] //beneficiaryConsent ); From 82d64083c9eaad92255d2a3fe6bd1da57817b1ea Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Fri, 4 Jul 2025 15:44:54 +0530 Subject: [PATCH 02/12] fix: AMM-1701 callcounter issue fix --- .../grievance/GrievanceDataSyncImpl.java | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/iemr/common/service/grievance/GrievanceDataSyncImpl.java b/src/main/java/com/iemr/common/service/grievance/GrievanceDataSyncImpl.java index 5a05b94f..35c9b57b 100644 --- a/src/main/java/com/iemr/common/service/grievance/GrievanceDataSyncImpl.java +++ b/src/main/java/com/iemr/common/service/grievance/GrievanceDataSyncImpl.java @@ -579,41 +579,48 @@ public String completeGrievanceCall(String request) throws Exception { // Logic for reattempt based on call group type and call type boolean isRetryNeeded = grievanceCallStatus.getRetryNeeded(); - if ((null != grievanceCallStatus.getComplaintResolution() - && grievanceCallStatus.getComplaintResolution().equalsIgnoreCase("Resolved")) || (callGroupType.equalsIgnoreCase("Valid") && (callType.equalsIgnoreCase("Valid") || callType.equals("Test Call")))) { + boolean isResolved = grievanceCallStatus.getComplaintResolution() != null + && grievanceCallStatus.getComplaintResolution().equalsIgnoreCase("Resolved"); + boolean isValidGroup = callGroupType.equalsIgnoreCase("Valid") + && (callType.equalsIgnoreCase("Valid") || callType.equals("Test Call")); + boolean isInvalidGroup = callGroupType.equalsIgnoreCase("Invalid") + && (callType.equalsIgnoreCase("Wrong Number") || callType.equalsIgnoreCase("Invalid Call")); + + if (isResolved) { + // 1) Any resolved call → complete, no retry isRetryNeeded = false; updateCount = grievanceDataRepo.updateCompletedStatusInCall(true, false, complaintID, userID, beneficiaryRegID); - } - else if (callGroupType.equalsIgnoreCase("Invalid") && (callType.equalsIgnoreCase("Wrong Number") || callType.equalsIgnoreCase("Invalid Call"))) { + + } else if (isValidGroup) { + // 2) Valid but not resolved → leave open, retry allowed + isRetryNeeded = true; + updateCount = grievanceDataRepo.updateCompletedStatusInCall(false, true, complaintID, userID, beneficiaryRegID); + + } else if (isInvalidGroup) { + // 3) Invalid calls → complete, no retry isRetryNeeded = false; - updateCount = grievanceDataRepo.updateCompletedStatusInCall(true, isRetryNeeded, complaintID, userID, - beneficiaryRegID); - }else { + updateCount = grievanceDataRepo.updateCompletedStatusInCall(true, false, complaintID, userID, beneficiaryRegID); + + } else { + // 4) All other cases (e.g. unreachable) → leave open, retry allowed isRetryNeeded = true; - updateCount = grievanceDataRepo.updateCompletedStatusInCall(false, isRetryNeeded, complaintID, - userID, beneficiaryRegID); + updateCount = grievanceDataRepo.updateCompletedStatusInCall(false, true, complaintID, userID, beneficiaryRegID); } - // Check if max attempts (3) are reached + + //Call counter update if (isRetryNeeded && grievanceCallStatus.getCallCounter() < grievanceAllocationRetryConfiguration) { grievanceCallStatus.setCallCounter(grievanceCallStatus.getCallCounter() + 1); - updateCallCounter = grievanceDataRepo.updateCallCounter(grievanceCallStatus.getCallCounter(), - isRetryNeeded, grievanceCallRequest.getComplaintID(), - grievanceCallRequest.getBeneficiaryRegID(), - grievanceCallRequest.getUserID()); - if (updateCallCounter > 0) - response = "Successfully closing call"; - else { - response = "failure in closing call"; - } - } else if (grievanceCallStatus.getCallCounter() == grievanceAllocationRetryConfiguration) { - // Max attempts reached, no further reattempt + updateCallCounter = grievanceDataRepo.updateCallCounter( + grievanceCallStatus.getCallCounter(), true, complaintID, beneficiaryRegID, userID); + response = (updateCallCounter > 0) ? "Successfully closing call" : "failure in closing call"; + + } else if (grievanceCallStatus.getCallCounter() >= grievanceAllocationRetryConfiguration) { + // Max attempts reached → treated as “complete” isRetryNeeded = false; - // isCompleted = true; - updateCount = grievanceDataRepo.updateCompletedStatusInCall(isCompleted, isRetryNeeded, complaintID, - userID, beneficiaryRegID); - response = "max_attempts_reached"; // Indicate that max attempts are reached + updateCount = grievanceDataRepo.updateCompletedStatusInCall(true, false, complaintID, userID, beneficiaryRegID); + response = "max_attempts_reached"; - }else if(updateCount > 0) { + } else if (updateCount > 0) { response = "Successfully closing call"; } From f8d543e1fb9111b207cf59579028ac25b8427255 Mon Sep 17 00:00:00 2001 From: ravishanigarapu <133210792+ravishanigarapu@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:30:30 +0530 Subject: [PATCH 03/12] Added Jwttoken as header while calling Identity-API (#274) * removed issanjeevani variable * Jwttoken added in Header for Identity-API calls * Coderabbit comments addressed --- .../iemr/common/utils/RestTemplateUtil.java | 84 +++++++++++++------ .../com/iemr/common/utils/http/HttpUtils.java | 35 +++----- 2 files changed, 69 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/iemr/common/utils/RestTemplateUtil.java b/src/main/java/com/iemr/common/utils/RestTemplateUtil.java index 447ba80f..c8299fe7 100644 --- a/src/main/java/com/iemr/common/utils/RestTemplateUtil.java +++ b/src/main/java/com/iemr/common/utils/RestTemplateUtil.java @@ -10,14 +10,17 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import com.iemr.common.constant.Constants; + import jakarta.servlet.http.HttpServletRequest; public class RestTemplateUtil { private final static Logger logger = LoggerFactory.getLogger(RestTemplateUtil.class); - + public static HttpEntity createRequestEntity(Object body, String authorization) { - - ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()); + + ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder + .getRequestAttributes()); if (servletRequestAttributes == null) { MultiValueMap headers = new LinkedMultiValueMap<>(); headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); @@ -25,29 +28,58 @@ public static HttpEntity createRequestEntity(Object body, String authori return new HttpEntity<>(body, headers); } HttpServletRequest requestHeader = servletRequestAttributes.getRequest(); - String jwtTokenFromCookie = null; + + String jwtTokenFromCookie = extractJwttoken(requestHeader); + + MultiValueMap headers = new LinkedMultiValueMap<>(); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); + if (null != UserAgentContext.getUserAgent()) { + headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); + } + headers.add(HttpHeaders.AUTHORIZATION, authorization); + if (null != requestHeader.getHeader(Constants.JWT_TOKEN)) { + headers.add(Constants.JWT_TOKEN, requestHeader.getHeader(Constants.JWT_TOKEN)); + } + if (null != jwtTokenFromCookie) { + headers.add(HttpHeaders.COOKIE, "Jwttoken=" + jwtTokenFromCookie); + } + + return new HttpEntity<>(body, headers); + } + + private static String extractJwttoken(HttpServletRequest requestHeader) { + String jwtTokenFromCookie = null; try { jwtTokenFromCookie = CookieUtil.getJwtTokenFromCookie(requestHeader); - + } catch (Exception e) { - logger.error("Error while getting jwtToken from Cookie" + e.getMessage() ); - } - - MultiValueMap headers = new LinkedMultiValueMap<>(); - headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); - if(null != UserAgentContext.getUserAgent()) { - logger.info("Common-API getting User-Agent as : "+UserAgentContext.getUserAgent()); - headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); - } - headers.add(HttpHeaders.AUTHORIZATION, authorization); - if(null != requestHeader.getHeader("JwtToken")) { - headers.add("JwtToken",requestHeader.getHeader("JwtToken")); - } - if(null != jwtTokenFromCookie) { - headers.add(HttpHeaders.COOKIE, "Jwttoken=" + jwtTokenFromCookie); - } - - return new HttpEntity<>(body, headers); - } - -} \ No newline at end of file + logger.error("Error while getting jwtToken from Cookie" + e.getMessage()); + } + return jwtTokenFromCookie; + } + + public static void getJwttokenFromHeaders(HttpHeaders headers) { + ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder + .getRequestAttributes()); + if (servletRequestAttributes == null) { + return; + } + HttpServletRequest requestHeader = servletRequestAttributes.getRequest(); + String jwtTokenFromCookie = extractJwttoken(requestHeader); + if (!headers.containsKey(HttpHeaders.CONTENT_TYPE)) { + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=utf-8"); + } + if (null != UserAgentContext.getUserAgent()) { + if (!headers.containsKey(HttpHeaders.USER_AGENT)) { + headers.add(HttpHeaders.USER_AGENT, UserAgentContext.getUserAgent()); + } + } + if (null != jwtTokenFromCookie) { + headers.add(HttpHeaders.COOKIE, Constants.JWT_TOKEN + "=" + jwtTokenFromCookie); + } else if (null != requestHeader.getHeader(Constants.JWT_TOKEN)) { + headers.add(Constants.JWT_TOKEN, requestHeader.getHeader(Constants.JWT_TOKEN)); + } + + } + +} diff --git a/src/main/java/com/iemr/common/utils/http/HttpUtils.java b/src/main/java/com/iemr/common/utils/http/HttpUtils.java index 0f308619..4f49e662 100644 --- a/src/main/java/com/iemr/common/utils/http/HttpUtils.java +++ b/src/main/java/com/iemr/common/utils/http/HttpUtils.java @@ -40,6 +40,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import com.iemr.common.utils.RestTemplateUtil; import com.sun.jersey.multipart.FormDataBodyPart; import com.sun.jersey.multipart.FormDataMultiPart; @@ -54,9 +55,6 @@ public class HttpUtils { // @Autowired private HttpStatus status; private final Logger logger = LoggerFactory.getLogger(this.getClass().getName()); - - // @Autowired(required = true) - // @Qualifier("hibernateCriteriaBuilder") public HttpUtils() { if (rest == null) { rest = new RestTemplate(); @@ -64,33 +62,22 @@ public HttpUtils() { headers.add("Content-Type", "application/json"); } } - // public HttpUtils() { - // if (rest == null) { - // rest = new RestTemplate(); - // headers = new HttpHeaders(); - // headers.add("Content-Type", "application/json"); - // } - // } - - // @Bean - // public HttpUtils httpUtils() { - // return new HttpUtils(); - // } - + public String get(String uri) { String body; - HttpEntity requestEntity = new HttpEntity("", headers); + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Content-Type", "application/json"); + RestTemplateUtil.getJwttokenFromHeaders(requestHeaders); + HttpEntity requestEntity = new HttpEntity("", requestHeaders); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); - // if (status == HttpStatus.OK){ body = responseEntity.getBody(); - // }else{ - // responseEntity - // } + return body; } public ResponseEntity getV1(String uri) throws URISyntaxException, MalformedURLException { + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity("", headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); return responseEntity; @@ -107,6 +94,7 @@ public String get(String uri, HashMap header) { } else { headers.add("Content-Type", MediaType.APPLICATION_JSON); } + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity("", headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.GET, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); @@ -116,6 +104,7 @@ public String get(String uri, HashMap header) { public String post(String uri, String json) { String body; + RestTemplateUtil.getJwttokenFromHeaders(headers); HttpEntity requestEntity = new HttpEntity(json, headers); ResponseEntity responseEntity = rest.exchange(uri, HttpMethod.POST, requestEntity, String.class); setStatus((HttpStatus) responseEntity.getStatusCode()); @@ -129,9 +118,7 @@ public String post(String uri, String data, HashMap header) { if (header.containsKey(headers.AUTHORIZATION)) { headers.add(headers.AUTHORIZATION, header.get(headers.AUTHORIZATION).toString()); } - - // headers.add("Content-Type", MediaType.APPLICATION_JSON); - + RestTemplateUtil.getJwttokenFromHeaders(headers); headers.add("Content-Type", MediaType.APPLICATION_JSON + ";charset=utf-8"); ResponseEntity responseEntity = new ResponseEntity(HttpStatus.BAD_REQUEST); HttpEntity requestEntity; From 5e39ca11dd9f787d84825de88e46c0df3c8290f7 Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Wed, 10 Sep 2025 21:14:23 +0530 Subject: [PATCH 04/12] feat: amm-1337 platform feedback module --- pom.xml | 2 +- src/main/environment/common_ci.properties | 13 +- src/main/environment/common_docker.properties | 13 +- .../environment/common_example.properties | 17 ++ .../com/iemr/common/config/RedisConfig.java | 7 + .../PlatformFeedbackController.java | 74 ++++++ .../data/platform_feedback/Feedback.java | 203 ++++++++++++++++ .../platform_feedback/FeedbackCategory.java | 157 +++++++++++++ .../platform_feedback/CategoryResponse.java | 24 ++ .../platform_feedback/FeedbackRequest.java | 38 +++ .../platform_feedback/FeedbackResponse.java | 26 +++ .../common/exception/BadRequestException.java | 31 +++ .../PlatformFeedbackRateLimitFilter.java | 216 ++++++++++++++++++ .../PlatformFeedbackCategoryRepository.java | 34 +++ .../PlatformFeedbackRepository.java | 30 +++ .../PlatformFeedbackService.java | 113 +++++++++ .../com/iemr/common/utils/FilterConfig.java | 102 +++++++-- .../utils/JwtUserIdValidationFilter.java | 41 ++++ 18 files changed, 1124 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java create mode 100644 src/main/java/com/iemr/common/data/platform_feedback/Feedback.java create mode 100644 src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java create mode 100644 src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java create mode 100644 src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java create mode 100644 src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java create mode 100644 src/main/java/com/iemr/common/exception/BadRequestException.java create mode 100644 src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java create mode 100644 src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java create mode 100644 src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java create mode 100644 src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java diff --git a/pom.xml b/pom.xml index d4749c5a..a2e6e019 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common-API common-api - 3.1.0 + 3.5.0 war Common-API diff --git a/src/main/environment/common_ci.properties b/src/main/environment/common_ci.properties index a5e66660..14e78c13 100644 --- a/src/main/environment/common_ci.properties +++ b/src/main/environment/common_ci.properties @@ -185,4 +185,15 @@ cors.allowed-origins=@env.CORS_ALLOWED_ORIGINS@ video-call-url=@env.VIDEO_CALL_URL@ jibri.output.path=@env.JIBRI_OUTPUT_PATH@ -video.recording.path=@env.VIDEO_RECORDING_PATH@ \ No newline at end of file +video.recording.path=@env.VIDEO_RECORDING_PATH@ + +# Platform Feedback module +platform.feedback.ratelimit.enabled=@env.PLATFORM_FEEDBACK_RATELIMIT_ENABLED@ +platform.feedback.ratelimit.pepper=@env.PLATFORM_FEEDBACK_RATELIMIT_PEPPER@ +platform.feedback.ratelimit.trust-forwarded-for=@env.PLATFORM_FEEDBACK_RATELIMIT_TRUST_FORWARDED_FOR@ +platform.feedback.ratelimit.forwarded-for-header=@env.PLATFORM_FEEDBACK_RATELIMIT_FORWARDED_FOR_HEADER@ +platform.feedback.ratelimit.minute-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_MINUTE_LIMIT@ +platform.feedback.ratelimit.day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT@ +platform.feedback.ratelimit.user-day-limit=@env.PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT@ +platform.feedback.ratelimit.fail-window-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES@ +platform.feedback.ratelimit.backoff-minutes=@env.PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES@ \ No newline at end of file diff --git a/src/main/environment/common_docker.properties b/src/main/environment/common_docker.properties index 41881886..d96a999e 100644 --- a/src/main/environment/common_docker.properties +++ b/src/main/environment/common_docker.properties @@ -187,4 +187,15 @@ cors.allowed-origins=${CORS_ALLOWED_ORIGINS} video-call-url=${VIDEO_CALL_URL} jibri.output.path={JIBRI_OUTPUT_PATH} -video.recording.path={VIDEO_RECORDING_PATH} \ No newline at end of file +video.recording.path={VIDEO_RECORDING_PATH} + +# Platform Feedback module +platform.feedback.ratelimit.enabled=${PLATFORM_FEEDBACK_RATELIMIT_ENABLED} +platform.feedback.ratelimit.pepper=${PLATFORM_FEEDBACK_RATELIMIT_PEPPER} +platform.feedback.ratelimit.trust-forwarded-for=${PLATFORM_FEEDBACK_RATELIMIT_TRUST_FORWARDED_FOR} +platform.feedback.ratelimit.forwarded-for-header=${PLATFORM_FEEDBACK_RATELIMIT_FORWARDED_FOR_HEADER} +platform.feedback.ratelimit.minute-limit=${PLATFORM_FEEDBACK_RATELIMIT_MINUTE_LIMIT} +platform.feedback.ratelimit.day-limit=${PLATFORM_FEEDBACK_RATELIMIT_DAY_LIMIT} +platform.feedback.ratelimit.user-day-limit=${PLATFORM_FEEDBACK_RATELIMIT_USER_DAY_LIMIT} +platform.feedback.ratelimit.fail-window-minutes=${PLATFORM_FEEDBACK_RATELIMIT_FAIL_WINDOW_MINUTES} +platform.feedback.ratelimit.backoff-minutes=${PLATFORM_FEEDBACK_RATELIMIT_BACKOFF_MINUTES} diff --git a/src/main/environment/common_example.properties b/src/main/environment/common_example.properties index 09a526dd..9aa75988 100644 --- a/src/main/environment/common_example.properties +++ b/src/main/environment/common_example.properties @@ -208,3 +208,20 @@ captcha.enable-captcha=true cors.allowed-origins=http://localhost:* +# ---Platform Feedback module --- +# Rate limiter OFF locally (no Redis required) +platform.feedback.ratelimit.enabled=true +platform.feedback.ratelimit.pepper=dev-pepper-123 # dummy + +# trust forwarded-for locally is harmless (localhost only) +platform.feedback.ratelimit.trust-forwarded-for=false +platform.feedback.ratelimit.forwarded-for-header=X-Forwarded-For + +# Optional overrides (not needed if disabled) +platform.feedback.ratelimit.minute-limit=10 +platform.feedback.ratelimit.day-limit=100 +platform.feedback.ratelimit.user-day-limit=50 +platform.feedback.ratelimit.fail-window-minutes=5 +platform.feedback.ratelimit.backoff-minutes=15 + + diff --git a/src/main/java/com/iemr/common/config/RedisConfig.java b/src/main/java/com/iemr/common/config/RedisConfig.java index faac71ae..e39692b7 100644 --- a/src/main/java/com/iemr/common/config/RedisConfig.java +++ b/src/main/java/com/iemr/common/config/RedisConfig.java @@ -30,6 +30,7 @@ import org.springframework.session.data.redis.config.ConfigureRedisAction; import com.iemr.common.data.users.User; +import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class RedisConfig { @@ -54,4 +55,10 @@ public RedisTemplate redisTemplate(RedisConnectionFactory factory) return template; } + // new bean for rate limiting & counters + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { + return new StringRedisTemplate(factory); + } + } diff --git a/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java b/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java new file mode 100644 index 00000000..fd9aad90 --- /dev/null +++ b/src/main/java/com/iemr/common/controller/platform_feedback/PlatformFeedbackController.java @@ -0,0 +1,74 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.controller; + +import com.iemr.common.dto.*; +import com.iemr.common.service.PlatformFeedbackService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.media.Content; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Platform Feedback", description = "Feedback ingestion and category listing for platform-wide feedback") +@RestController +@RequestMapping("/platform-feedback") +@Validated +public class PlatformFeedbackController { + + private final PlatformFeedbackService service; + + public PlatformFeedbackController(PlatformFeedbackService service) { + this.service = service; + } + + @Operation(summary = "Submit feedback (public endpoint)", + description = "Accepts feedback (anonymous or identified). Accepts categoryId or categorySlug; slug is preferred.") + @ApiResponse(responseCode = "201", description = "Feedback accepted") + @ApiResponse(responseCode = "400", description = "Validation or business error", content = @Content) + @PostMapping + public ResponseEntity submit( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Feedback payload") + @Valid @RequestBody FeedbackRequest req) { + FeedbackResponse resp = service.submitFeedback(req); + return ResponseEntity.status(HttpStatus.CREATED).body(resp); + } + + @Operation(summary = "List active categories", + description = "Returns active categories. Optionally filter by serviceLine (frontend convenience).") + @ApiResponse(responseCode = "200", description = "List of categories") + @GetMapping("/categories") + public ResponseEntity> list( + @Parameter(description = "Optional serviceLine to prefer scopes (1097|104|AAM|MMU|TM|ECD)") + @RequestParam(required = false) String serviceLine) { + if (serviceLine == null) serviceLine = "GLOBAL"; + List list = service.listCategories(serviceLine); + return ResponseEntity.ok(list); + } +} diff --git a/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java new file mode 100644 index 00000000..cbce9b07 --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java @@ -0,0 +1,203 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Represents feedback provided by users for a specific service or category. + * Mapped to "m_platform_feedback". + */ +@Entity +@Table(name = "m_platform_feedback") +public class Feedback { + + @Id + @Column(name = "FeedbackID", length = 36, updatable = false, nullable = false) + private String feedbackId; + + @Column(name = "CreatedAt", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false) + private LocalDateTime updatedAt; + + @Min(1) + @Max(5) + @Column(name = "Rating", nullable = false) + private int rating; + + @NotBlank(message = "Comment cannot be blank") + @Size(max = 2000, message = "Comment cannot exceed 2000 characters") + @Column(name = "Comment", columnDefinition = "TEXT", nullable = false) + private String comment; + + @Column(name = "ServiceLine", nullable = false, length = 10) + private String serviceLine; + + @Column(name = "IsAnonymous", nullable = false) + private boolean isAnonymous; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "CategoryID", referencedColumnName = "CategoryID", nullable = false) + private FeedbackCategory category; + + /** + * We store the numeric UserID if the submission is identified. We don't map to a User + * entity here to avoid cross-module coupling; the DB should have the FK enforced if desired. + */ + @Column(name = "UserID") + private Integer userId; + + // ===== Constructors ===== + public Feedback() { + // default constructor for JPA + } + + // convenience constructor (optional) + public Feedback(int rating, String comment, String serviceLine, boolean isAnonymous, FeedbackCategory category, Integer userId) { + this.feedbackId = UUID.randomUUID().toString(); + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + this.setRating(rating); + this.setComment(comment); + this.setServiceLine(serviceLine); + this.isAnonymous = isAnonymous; + this.category = category; + this.userId = userId; + } + + // ======= JPA lifecycle callbacks ======= + @PrePersist + protected void onCreate() { + if (this.feedbackId == null) { + this.feedbackId = UUID.randomUUID().toString(); + } + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + this.updatedAt = this.createdAt; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // ======= Getters & Setters ======= + public String getFeedbackId() { + return feedbackId; + } + + public void setFeedbackId(String feedbackId) { + this.feedbackId = feedbackId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + // createdAt should only be set once; still exposing setter if needed + public void setCreatedAt(LocalDateTime createdAt) { + if (this.createdAt == null) { + this.createdAt = createdAt; + } + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + // updatedAt is managed by lifecycle callbacks but a setter is fine for tests/migration + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public int getRating() { + return rating; + } + + public void setRating(int rating) { + if (rating < 1 || rating > 5) { + throw new IllegalArgumentException("Rating must be between 1 and 5."); + } + this.rating = rating; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + if (comment == null || comment.trim().isEmpty()) { + throw new IllegalArgumentException("Comment cannot be blank"); + } + if (comment.length() > 2000) { + throw new IllegalArgumentException("Comment cannot exceed 2000 characters"); + } + this.comment = comment; + } + + public String getServiceLine() { + return serviceLine; + } + + public void setServiceLine(String serviceLine) { + if (serviceLine == null || serviceLine.trim().isEmpty()) { + throw new IllegalArgumentException("ServiceLine must not be null or empty."); + } + this.serviceLine = serviceLine; + } + + public boolean isAnonymous() { + return isAnonymous; + } + + public void setAnonymous(boolean anonymous) { + isAnonymous = anonymous; + } + + public FeedbackCategory getCategory() { + return category; + } + + public void setCategory(FeedbackCategory category) { + if (category == null) { + throw new IllegalArgumentException("Category must not be null."); + } + this.category = category; + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } +} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java new file mode 100644 index 00000000..490805c4 --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java @@ -0,0 +1,157 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * FeedbackCategory maps to the m_feedback_category table. + */ +@Entity +@Table(name = "m_feedback_category", uniqueConstraints = { + @UniqueConstraint(name = "uq_category_slug", columnNames = "Slug") +}) +public class FeedbackCategory { + + @Id + @Column(name = "CategoryID", length = 36, updatable = false, nullable = false) + private String categoryId; + + @NotBlank + @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$", message = "Slug must be lowercase alphanumeric with optional single dashes") + @Size(max = 64) + @Column(name = "Slug", nullable = false, length = 64) + private String slug; + + @NotBlank + @Size(max = 128) + @Column(name = "Label", nullable = false, length = 128) + private String label; + + @NotBlank + @Size(max = 20) + @Column(name = "Scope", nullable = false, length = 20) + private String scope; + + @Column(name = "Active", nullable = false) + private boolean active; + + @Column(name = "CreatedAt", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false) + private LocalDateTime updatedAt; + + // ===== Constructors ===== + public FeedbackCategory() { + // default ctor for JPA + } + + public FeedbackCategory(String slug, String label, String scope, boolean active) { + this.slug = slug; + this.label = label; + this.scope = scope; + this.active = active; + } + + // ===== JPA lifecycle hooks ===== + @PrePersist + protected void onCreate() { + if (this.categoryId == null) { + this.categoryId = UUID.randomUUID().toString(); + } + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + this.updatedAt = this.createdAt; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // ===== Getters & Setters ===== + public String getCategoryId() { + return categoryId; + } + + // categoryId is generated at persist; setter kept for migration/tests + public void setCategoryId(String categoryId) { + this.categoryId = categoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + // CreatedAt normally set by lifecycle; setter available for tests/migrations + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + // UpdatedAt normally managed by lifecycle + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java b/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java new file mode 100644 index 00000000..df1c7184 --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/CategoryResponse.java @@ -0,0 +1,24 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; + +public record CategoryResponse(String categoryId, String slug, String label, String scope, boolean active) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java new file mode 100644 index 00000000..7002ece9 --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackRequest.java @@ -0,0 +1,38 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +public record FeedbackRequest( + @Min(1) @Max(5) int rating, + String categoryId, + String categorySlug, + @Size(max = 2000) String comment, + boolean isAnonymous, + @Pattern(regexp = "^(1097|104|AAM|MMU|TM|ECD)$") String serviceLine, + Integer userId +) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java new file mode 100644 index 00000000..6b348f6e --- /dev/null +++ b/src/main/java/com/iemr/common/dto/platform_feedback/FeedbackResponse.java @@ -0,0 +1,26 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.dto; +import java.time.LocalDateTime; + + +public record FeedbackResponse(String id, LocalDateTime createdAt) {} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/exception/BadRequestException.java b/src/main/java/com/iemr/common/exception/BadRequestException.java new file mode 100644 index 00000000..65d146a6 --- /dev/null +++ b/src/main/java/com/iemr/common/exception/BadRequestException.java @@ -0,0 +1,31 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { super(message); } + public BadRequestException(String message, Throwable cause) { super(message, cause); } +} diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java new file mode 100644 index 00000000..95126093 --- /dev/null +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -0,0 +1,216 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + + +@Component +@ConditionalOnProperty(prefix = "platform.feedback.ratelimit", name = "enabled", havingValue = "true", matchIfMissing = false) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) // run early (adjust order as needed) +public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { + + private final StringRedisTemplate redis; + private final String pepper; + private final boolean trustForwardedFor; + private final String forwardedForHeader; + + // Limits & TTLs (tweak if needed) + private static final int MINUTE_LIMIT = 10; + private static final int DAY_LIMIT = 100; + private static final int USER_DAY_LIMIT = 50; // for identified users + private static final Duration MINUTE_WINDOW = Duration.ofMinutes(1); + private static final Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h + private static final Duration FAIL_COUNT_WINDOW = Duration.ofMinutes(5); + private static final int FAILS_TO_BACKOFF = 3; + private static final Duration BACKOFF_WINDOW = Duration.ofMinutes(15); + + public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis, + org.springframework.core.env.Environment env) { + this.redis = redis; + this.pepper = env.getProperty("platform.feedback.pepper", ""); + this.trustForwardedFor = Boolean.parseBoolean(env.getProperty("platform.feedback.trust-forwarded-for", "true")); + this.forwardedForHeader = env.getProperty("platform.feedback.forwarded-for-header", "X-Forwarded-For"); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + // Only filter specific endpoints (POST to platform-feedback). Keep it narrow. + String path = request.getRequestURI(); + String method = request.getMethod(); + // adjust path as needed (supports /common-api/platform-feedback and subpaths) + return !("POST".equalsIgnoreCase(method) && path != null && path.matches("^/platform-feedback(?:/.*)?$")); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // compute day key + String clientIp = extractClientIp(request); + if (clientIp == null || clientIp.isBlank()) { + // If we can't identify an IP, be conservative and allow but log (or optionally block) + filterChain.doFilter(request, response); + return; + } + + String today = LocalDate.now(ZoneId.of("Asia/Kolkata")).toString().replaceAll("-", ""); // yyyyMMdd + String ipHash = sha256Base64(clientIp + pepper + today); // base64 shorter storage; not reversible without pepper + String minKey = "rl:fb:min:" + ipHash + ":" + (System.currentTimeMillis() / 60000L); // minute-slotted + String dayKey = "rl:fb:day:" + today + ":" + ipHash; + String failKey = "rl:fb:fail:" + ipHash; + String backoffKey = "rl:fb:backoff:" + ipHash; + + // If under backoff -> respond 429 with Retry-After = TTL + Long backoffTtl = getTtlSeconds(backoffKey); + if (backoffTtl != null && backoffTtl > 0) { + sendTooMany(response, backoffTtl); + return; + } + + // Minute window check (INCR + TTL if first) + long minuteCount = incrementWithExpire(minKey, 1, MINUTE_WINDOW.getSeconds()); + if (minuteCount > MINUTE_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); + return; + } + + // Day window check + long dayCount = incrementWithExpire(dayKey, 1, DAY_WINDOW.getSeconds()); + if (dayCount > DAY_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); + return; + } + + // Optional: per-user daily cap if we can extract an authenticated user id from header/jwt + Integer userId = extractUserIdFromRequest(request); // implement extraction as per your JWT scheme + if (userId != null) { + String userDayKey = "rl:fb:user:" + today + ":" + userId; + long ucount = incrementWithExpire(userDayKey, 1, DAY_WINDOW.getSeconds()); + if (ucount > USER_DAY_LIMIT) { + handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, userDayKey); + return; + } + } + + // All checks passed — proceed to controller + filterChain.doFilter(request, response); + } + + // increments key by delta; sets TTL when key is new (INCR returns 1) + private long incrementWithExpire(String key, long delta, long ttlSeconds) { + Long value = redis.opsForValue().increment(key, delta); + if (value != null && value == 1L) { + redis.expire(key, ttlSeconds, TimeUnit.SECONDS); + } + return value == null ? 0L : value; + } + + private void handleFailureAndMaybeBackoff(String failKey, String backoffKey, HttpServletResponse response, String trigKey, String dayKey) throws IOException { + // increment fail counter and possibly set backoff + Long fails = redis.opsForValue().increment(failKey, 1); + if (fails != null && fails == 1L) { + redis.expire(failKey, FAIL_COUNT_WINDOW.getSeconds(), TimeUnit.SECONDS); + } + if (fails != null && fails >= FAILS_TO_BACKOFF) { + // set backoff flag + redis.opsForValue().set(backoffKey, "1", BACKOFF_WINDOW.getSeconds(), TimeUnit.SECONDS); + sendTooMany(response, BACKOFF_WINDOW.getSeconds()); + return; + } + + // otherwise respond with Retry-After for the triggering key TTL (minute/day) + Long retryAfter = getTtlSeconds(trigKey); + if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; + sendTooMany(response, retryAfter); + } + + private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) throws IOException { + response.setStatus(429); + response.setHeader("Retry-After", String.valueOf(retryAfterSeconds)); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + String body = String.format("{\"code\":\"RATE_LIMITED\",\"message\":\"Too many requests\",\"retryAfter\":%d}", retryAfterSeconds); + response.getWriter().write(body); + } + + private Long getTtlSeconds(String key) { + Long ttl = redis.getExpire(key, TimeUnit.SECONDS); + return ttl == null || ttl < 0 ? null : ttl; + } + + private String extractClientIp(HttpServletRequest request) { + if (trustForwardedFor) { + String header = request.getHeader(forwardedForHeader); + if (StringUtils.hasText(header)) { + // X-Forwarded-For may contain comma-separated list; take the first (client) entry + String[] parts = header.split(","); + if (parts.length > 0) { + String ip = parts[0].trim(); + if (StringUtils.hasText(ip)) return ip; + } + } + } + return request.getRemoteAddr(); + } + + private Integer extractUserIdFromRequest(HttpServletRequest request) { + // implement based on how you propagate JWT or user info. + // Example: if your gateway injects header X-User-Id for authenticated requests: + String s = request.getHeader("X-User-Id"); + if (StringUtils.hasText(s)) { + try { return Integer.valueOf(s); } catch (NumberFormatException ignored) {} + } + // If JWT parsing required, do it here, but keep this filter light — prefer upstream auth filter to populate a header. + return null; + } + + private static String sha256Base64(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + // base64 url-safe or normal base64 — either is fine; base64 is shorter than hex + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } catch (Exception ex) { + throw new RuntimeException("sha256 failure", ex); + } + } +} diff --git a/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java new file mode 100644 index 00000000..083ca5f7 --- /dev/null +++ b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackCategoryRepository.java @@ -0,0 +1,34 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.repository.platform_feedback; + +import com.iemr.common.model.FeedbackCategory; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlatformFeedbackCategoryRepository extends JpaRepository { + Optional findBySlugIgnoreCase(String slug); + List findByActiveTrueOrderByLabelAsc(); +} diff --git a/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java new file mode 100644 index 00000000..fd00bdbc --- /dev/null +++ b/src/main/java/com/iemr/common/repository/platform_feedback/PlatformFeedbackRepository.java @@ -0,0 +1,30 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.repository.platform_feedback; + +import com.iemr.common.model.Feedback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlatformFeedbackRepository extends JpaRepository { +} diff --git a/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java b/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java new file mode 100644 index 00000000..5149e3ed --- /dev/null +++ b/src/main/java/com/iemr/common/service/platform_feedback/PlatformFeedbackService.java @@ -0,0 +1,113 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +package com.iemr.common.service; + +import com.iemr.common.dto.*; +import com.iemr.common.model.Feedback; +import com.iemr.common.model.FeedbackCategory; +import com.iemr.common.repository.platform_feedback.PlatformFeedbackCategoryRepository; +import com.iemr.common.repository.platform_feedback.PlatformFeedbackRepository; +import com.iemr.common.exception.BadRequestException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class PlatformFeedbackService { + + private final PlatformFeedbackRepository feedbackRepo; + private final PlatformFeedbackCategoryRepository categoryRepo; + + public PlatformFeedbackService(PlatformFeedbackRepository feedbackRepo, + PlatformFeedbackCategoryRepository categoryRepo) { + this.feedbackRepo = feedbackRepo; + this.categoryRepo = categoryRepo; + } + + @Transactional + public FeedbackResponse submitFeedback(FeedbackRequest req) { + // basic validations + if (req.rating() < 1 || req.rating() > 5) { + throw new BadRequestException("rating must be between 1 and 5"); + } + if (!req.isAnonymous() && req.userId() == null) { + throw new BadRequestException("userId required when isAnonymous=false"); + } + + FeedbackCategory category = resolveCategory(req.categoryId(), req.categorySlug(), req.serviceLine()); + + Feedback fb = new Feedback(); + fb.setFeedbackId(UUID.randomUUID().toString()); + fb.setCreatedAt(LocalDateTime.now()); + fb.setUpdatedAt(LocalDateTime.now()); + fb.setRating(req.rating()); + fb.setComment(req.comment() == null ? "" : req.comment()); + fb.setServiceLine(req.serviceLine()); + fb.setAnonymous(req.isAnonymous()); + fb.setCategory(category); + fb.setUserId(req.userId()); + + feedbackRepo.save(fb); + return new FeedbackResponse(fb.getFeedbackId(), fb.getCreatedAt()); + } + + private FeedbackCategory resolveCategory(String categoryId, String categorySlug, String serviceLine) { + if (categoryId != null && categorySlug != null) { + FeedbackCategory byId = categoryRepo.findById(categoryId) + .orElseThrow(() -> new BadRequestException("invalid categoryId")); + if (!byId.getSlug().equalsIgnoreCase(categorySlug)) { + throw new BadRequestException("categoryId and categorySlug mismatch"); + } + if (!byId.isActive()) throw new BadRequestException("category inactive"); + // optional: check scope matches serviceLine or GLOBAL + return byId; + } + + if (categoryId != null) { + FeedbackCategory byId = categoryRepo.findById(categoryId) + .orElseThrow(() -> new BadRequestException("invalid categoryId")); + if (!byId.isActive()) throw new BadRequestException("category inactive"); + return byId; + } + + if (categorySlug != null) { + FeedbackCategory bySlug = categoryRepo.findBySlugIgnoreCase(categorySlug) + .orElseThrow(() -> new BadRequestException("invalid categorySlug")); + if (!bySlug.isActive()) throw new BadRequestException("category inactive"); + return bySlug; + } + + throw new BadRequestException("categoryId or categorySlug required"); + } + + @Transactional(readOnly = true) + public List listCategories(String serviceLine) { + List all = categoryRepo.findByActiveTrueOrderByLabelAsc(); + // filter by scope or return all; FE can filter further + return all.stream() + .filter(c -> "GLOBAL".equals(c.getScope()) || c.getScope().equalsIgnoreCase(serviceLine)) + .map(c -> new CategoryResponse(c.getCategoryId(), c.getSlug(), c.getLabel(), c.getScope(), c.isActive())) + .toList(); + } +} diff --git a/src/main/java/com/iemr/common/utils/FilterConfig.java b/src/main/java/com/iemr/common/utils/FilterConfig.java index 9f6efb13..8664199c 100644 --- a/src/main/java/com/iemr/common/utils/FilterConfig.java +++ b/src/main/java/com/iemr/common/utils/FilterConfig.java @@ -1,28 +1,102 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ package com.iemr.common.utils; +import com.iemr.common.filter.PlatformFeedbackRateLimitFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class FilterConfig { - @Value("${cors.allowed-origins}") - private String allowedOrigins; + private static final Logger log = LoggerFactory.getLogger(FilterConfig.class); + + @Value("${cors.allowed-origins}") + private String allowedOrigins; + + @Bean + public FilterRegistrationBean jwtUserIdValidationFilter( + JwtAuthenticationUtil jwtAuthenticationUtil) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + + // Pass allowedOrigins explicitly to the filter constructor + JwtUserIdValidationFilter filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, allowedOrigins); + + registrationBean.setFilter(filter); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); + registrationBean.addUrlPatterns("/*"); // Apply filter to all API endpoints + log.info("Registered JwtUserIdValidationFilter on /* with order {}", Ordered.HIGHEST_PRECEDENCE); + return registrationBean; + } + + /** + * Register the platform feedback rate-limit filter in a non-invasive way. + * + * - The filter is mapped only to the public feedback endpoints to avoid affecting other routes. + * - Order is intentionally set after the Jwt filter so authentication runs first. + */ + @Bean + public FilterRegistrationBean platformFeedbackRateLimitFilter( + StringRedisTemplate stringRedisTemplate, + Environment env) { + + // Read flag from environment (property file or env var) + boolean enabled = Boolean.parseBoolean(env.getProperty("platform.feedback.ratelimit.enabled", "false")); + + // Allow optional override for order if needed + int defaultOrder = Ordered.HIGHEST_PRECEDENCE + 10; + int order = defaultOrder; + String orderStr = env.getProperty("platform.feedback.ratelimit.order"); + if (orderStr != null) { + try { + order = Integer.parseInt(orderStr); + } catch (NumberFormatException e) { + log.warn("Invalid platform.feedback.ratelimit.order value '{}', using default {}", orderStr, defaultOrder); + } + } + + PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate, env); + + FilterRegistrationBean reg = new FilterRegistrationBean<>(filter); + + reg.addUrlPatterns( + "/platform-feedback/*", + "/platform-feedback" + ); - @Bean - public FilterRegistrationBean jwtUserIdValidationFilter( - JwtAuthenticationUtil jwtAuthenticationUtil) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + reg.setOrder(order); + // Do not remove the bean from context when disabled; keep registration but disable execution + reg.setEnabled(enabled); - // Pass allowedOrigins explicitly to the filter constructor - JwtUserIdValidationFilter filter = new JwtUserIdValidationFilter(jwtAuthenticationUtil, allowedOrigins); + log.info("Registered PlatformFeedbackRateLimitFilter (enabled={}, order={}) mapped to {}", + enabled, order, reg.getUrlPatterns()); - registrationBean.setFilter(filter); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - registrationBean.addUrlPatterns("/*"); // Apply filter to all API endpoints - return registrationBean; - } + return reg; + } } diff --git a/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java b/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java index 0b8f6f94..0f35e6c5 100644 --- a/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java +++ b/src/main/java/com/iemr/common/utils/JwtUserIdValidationFilter.java @@ -1,3 +1,24 @@ +/* + * AMRIT – Accessible Medical Records via Integrated Technology + * Integrated EHR (Electronic Health Records) Solution + * + * Copyright (C) "Piramal Swasthya Management and Research Institute" + * + * This file is part of AMRIT. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ package com.iemr.common.utils; import java.io.IOException; @@ -59,6 +80,13 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo String contextPath = request.getContextPath(); logger.info("JwtUserIdValidationFilter invoked for path: " + path); + // NEW: if this is a platform-feedback endpoint, treat it as public (skip auth) + // and also ensure we don't clear any user cookies for these requests. + if (isPlatformFeedbackPath(path, contextPath)) { + logger.debug("Platform-feedback path detected - skipping authentication and leaving cookies intact: {}", path); + filterChain.doFilter(servletRequest, servletResponse); + return; + } // Log cookies for debugging Cookie[] cookies = request.getCookies(); if (cookies != null) { @@ -127,6 +155,19 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo } } + /** + * New helper: identifies platform-feedback endpoints so we can treat them + * specially (public + preserve cookies). + */ + private boolean isPlatformFeedbackPath(String path, String contextPath) { + if (path == null) return false; + String normalized = path.toLowerCase(); + String base = (contextPath == null ? "" : contextPath).toLowerCase(); + // match /platform-feedback and anything under it + return normalized.startsWith(base + "/platform-feedback"); + } + + private boolean isOriginAllowed(String origin) { if (origin == null || allowedOrigins == null || allowedOrigins.trim().isEmpty()) { logger.warn("No allowed origins configured or origin is null"); From d2f44b1d0a303c212f6f953a16facdd3e1af7acf Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Thu, 11 Sep 2025 12:58:31 +0530 Subject: [PATCH 05/12] feat: amm-1337 entities updated based on change in DDL --- .../data/platform_feedback/Feedback.java | 70 +++++-------------- .../platform_feedback/FeedbackCategory.java | 30 ++++---- 2 files changed, 32 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java index cbce9b07..b499ef3a 100644 --- a/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java +++ b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java @@ -24,15 +24,10 @@ import jakarta.persistence.*; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.UUID; -/** - * Represents feedback provided by users for a specific service or category. - * Mapped to "m_platform_feedback". - */ @Entity @Table(name = "m_platform_feedback") public class Feedback { @@ -41,10 +36,10 @@ public class Feedback { @Column(name = "FeedbackID", length = 36, updatable = false, nullable = false) private String feedbackId; - @Column(name = "CreatedAt", nullable = false) + @Column(name = "CreatedAt", nullable = false, insertable = false, updatable = false) private LocalDateTime createdAt; - @Column(name = "UpdatedAt", nullable = false) + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) private LocalDateTime updatedAt; @Min(1) @@ -52,38 +47,29 @@ public class Feedback { @Column(name = "Rating", nullable = false) private int rating; - @NotBlank(message = "Comment cannot be blank") - @Size(max = 2000, message = "Comment cannot exceed 2000 characters") - @Column(name = "Comment", columnDefinition = "TEXT", nullable = false) + @Size(max = 2000) + @Column(name = "Comment", columnDefinition = "TEXT", nullable = true) private String comment; @Column(name = "ServiceLine", nullable = false, length = 10) private String serviceLine; @Column(name = "IsAnonymous", nullable = false) - private boolean isAnonymous; + private boolean isAnonymous = true; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "CategoryID", referencedColumnName = "CategoryID", nullable = false) private FeedbackCategory category; - /** - * We store the numeric UserID if the submission is identified. We don't map to a User - * entity here to avoid cross-module coupling; the DB should have the FK enforced if desired. - */ - @Column(name = "UserID") + @Column(name = "UserID", nullable = true) private Integer userId; - // ===== Constructors ===== public Feedback() { - // default constructor for JPA + this.feedbackId = UUID.randomUUID().toString(); } - // convenience constructor (optional) public Feedback(int rating, String comment, String serviceLine, boolean isAnonymous, FeedbackCategory category, Integer userId) { - this.feedbackId = UUID.randomUUID().toString(); - this.createdAt = LocalDateTime.now(); - this.updatedAt = this.createdAt; + this(); // ensures feedbackId this.setRating(rating); this.setComment(comment); this.setServiceLine(serviceLine); @@ -92,28 +78,11 @@ public Feedback(int rating, String comment, String serviceLine, boolean isAnonym this.userId = userId; } - // ======= JPA lifecycle callbacks ======= - @PrePersist - protected void onCreate() { - if (this.feedbackId == null) { - this.feedbackId = UUID.randomUUID().toString(); - } - if (this.createdAt == null) { - this.createdAt = LocalDateTime.now(); - } - this.updatedAt = this.createdAt; - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } - - // ======= Getters & Setters ======= public String getFeedbackId() { return feedbackId; } + // Don't usually set feedbackId externally, but keep setter for testing/migration if needed public void setFeedbackId(String feedbackId) { this.feedbackId = feedbackId; } @@ -122,18 +91,14 @@ public LocalDateTime getCreatedAt() { return createdAt; } - // createdAt should only be set once; still exposing setter if needed public void setCreatedAt(LocalDateTime createdAt) { - if (this.createdAt == null) { - this.createdAt = createdAt; - } + this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } - // updatedAt is managed by lifecycle callbacks but a setter is fine for tests/migration public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } @@ -154,13 +119,10 @@ public String getComment() { } public void setComment(String comment) { - if (comment == null || comment.trim().isEmpty()) { - throw new IllegalArgumentException("Comment cannot be blank"); - } - if (comment.length() > 2000) { + if (comment != null && comment.length() > 2000) { throw new IllegalArgumentException("Comment cannot exceed 2000 characters"); } - this.comment = comment; + this.comment = (comment == null || comment.trim().isEmpty()) ? null : comment.trim(); } public String getServiceLine() { @@ -180,6 +142,9 @@ public boolean isAnonymous() { public void setAnonymous(boolean anonymous) { isAnonymous = anonymous; + if (anonymous) { + this.userId = null; + } } public FeedbackCategory getCategory() { @@ -199,5 +164,8 @@ public Integer getUserId() { public void setUserId(Integer userId) { this.userId = userId; + if (userId != null) { + this.isAnonymous = false; + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java index 490805c4..ee9ffffe 100644 --- a/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java +++ b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java @@ -58,17 +58,18 @@ public class FeedbackCategory { private String scope; @Column(name = "Active", nullable = false) - private boolean active; + private boolean active = true; - @Column(name = "CreatedAt", nullable = false) + + @Column(name = "CreatedAt", nullable = false, insertable = false, updatable = false) private LocalDateTime createdAt; - @Column(name = "UpdatedAt", nullable = false) + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) private LocalDateTime updatedAt; // ===== Constructors ===== public FeedbackCategory() { - // default ctor for JPA + // nothing — categoryId will be generated at persist if not provided } public FeedbackCategory(String slug, String label, String scope, boolean active) { @@ -79,20 +80,15 @@ public FeedbackCategory(String slug, String label, String scope, boolean active) } // ===== JPA lifecycle hooks ===== + /** + * Ensure CategoryID exists for new rows created via JPA. If DB seeding provides IDs, + * those will be used instead (we only set if null). + */ @PrePersist - protected void onCreate() { - if (this.categoryId == null) { + protected void ensureId() { + if (this.categoryId == null || this.categoryId.trim().isEmpty()) { this.categoryId = UUID.randomUUID().toString(); } - if (this.createdAt == null) { - this.createdAt = LocalDateTime.now(); - } - this.updatedAt = this.createdAt; - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); } // ===== Getters & Setters ===== @@ -141,7 +137,7 @@ public LocalDateTime getCreatedAt() { return createdAt; } - // CreatedAt normally set by lifecycle; setter available for tests/migrations + // CreatedAt normally set by DB; setter available for tests/migrations public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } @@ -150,7 +146,7 @@ public LocalDateTime getUpdatedAt() { return updatedAt; } - // UpdatedAt normally managed by lifecycle + // UpdatedAt normally managed by DB public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } From 19ade966beecedb1852692055642c3b0ae834b4b Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Wed, 17 Sep 2025 18:26:35 +0530 Subject: [PATCH 06/12] fix: dev-linux issue fix --- .../beneficiary/IdentityBeneficiaryServiceImpl.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java index 697322c6..7aa143dc 100644 --- a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java @@ -45,6 +45,7 @@ import com.iemr.common.utils.mapper.InputMapper; import com.iemr.common.utils.mapper.OutputMapper; import com.iemr.common.utils.response.OutputResponse; +import org.springframework.beans.factory.annotation.Value; @Service public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryService { @@ -54,8 +55,10 @@ public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryServic Logger logger = LoggerFactory.getLogger(this.getClass().getName()); private static HttpUtils httpUtils = new HttpUtils(); private InputMapper inputMapper = new InputMapper(); - private String identityBaseURL = ConfigProperties.getPropertyByName("identity-api-url"); - private String identity1097BaseURL = ConfigProperties.getPropertyByName("identity-1097-api-url"); + @Value("${identity-api-url}") + private String identityBaseURL; + @Value("${identity-1097-api-url}") + private String identity1097BaseURL; private static final String IDENTITY_BASE_URL = "IDENTITY_BASE_URL"; private static final String BEN_GEN = ConfigProperties.getPropertyByName("genben-api"); From 8635a020ee50d68357b234504c7006806b6b5469 Mon Sep 17 00:00:00 2001 From: Amoghavarsh <93114621+5Amogh@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:35:18 +0530 Subject: [PATCH 07/12] fix: dev-linux issue fix (#291) --- .../beneficiary/IdentityBeneficiaryServiceImpl.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java index 697322c6..7aa143dc 100644 --- a/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java +++ b/src/main/java/com/iemr/common/service/beneficiary/IdentityBeneficiaryServiceImpl.java @@ -45,6 +45,7 @@ import com.iemr.common.utils.mapper.InputMapper; import com.iemr.common.utils.mapper.OutputMapper; import com.iemr.common.utils.response.OutputResponse; +import org.springframework.beans.factory.annotation.Value; @Service public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryService { @@ -54,8 +55,10 @@ public class IdentityBeneficiaryServiceImpl implements IdentityBeneficiaryServic Logger logger = LoggerFactory.getLogger(this.getClass().getName()); private static HttpUtils httpUtils = new HttpUtils(); private InputMapper inputMapper = new InputMapper(); - private String identityBaseURL = ConfigProperties.getPropertyByName("identity-api-url"); - private String identity1097BaseURL = ConfigProperties.getPropertyByName("identity-1097-api-url"); + @Value("${identity-api-url}") + private String identityBaseURL; + @Value("${identity-1097-api-url}") + private String identity1097BaseURL; private static final String IDENTITY_BASE_URL = "IDENTITY_BASE_URL"; private static final String BEN_GEN = ConfigProperties.getPropertyByName("genben-api"); From 0505d4eaf599fce592c44a6410929e5c66baa3e6 Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Thu, 18 Sep 2025 14:24:34 +0530 Subject: [PATCH 08/12] fix: dev-linux issue fix --- .../PlatformFeedbackRateLimitFilter.java | 39 +++++++++++-------- .../com/iemr/common/utils/FilterConfig.java | 24 ++++-------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java index 95126093..fcf286d1 100644 --- a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -42,6 +42,7 @@ import java.util.Base64; import java.util.concurrent.TimeUnit; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.beans.factory.annotation.Value; @Component @@ -50,26 +51,30 @@ public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { private final StringRedisTemplate redis; - private final String pepper; - private final boolean trustForwardedFor; - private final String forwardedForHeader; + @Value("${platform.feedback.ratelimit.pepper}") + private String pepper; + @Value("${platform.feedback.ratelimit.trust-forwarded-for:false}") + private boolean trustForwardedFor; + @Value("${platform.feedback.ratelimit.forwarded-for-header:X-Forwarded-For}") + private String forwardedForHeader; // Limits & TTLs (tweak if needed) - private static final int MINUTE_LIMIT = 10; - private static final int DAY_LIMIT = 100; - private static final int USER_DAY_LIMIT = 50; // for identified users - private static final Duration MINUTE_WINDOW = Duration.ofMinutes(1); - private static final Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h - private static final Duration FAIL_COUNT_WINDOW = Duration.ofMinutes(5); - private static final int FAILS_TO_BACKOFF = 3; - private static final Duration BACKOFF_WINDOW = Duration.ofMinutes(15); - - public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis, - org.springframework.core.env.Environment env) { + @Value("${platform.feedback.ratelimit.minute-limit:10}") + private int MINUTE_LIMIT; + @Value("${platform.feedback.ratelimit.day-limit:100}") + private int DAY_LIMIT; + @Value("${platform.feedback.ratelimit.user-day-limit:50}") + private int USER_DAY_LIMIT; // for identified users + private Duration MINUTE_WINDOW = Duration.ofMinutes(1); + private Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h + @Value("${platform.feedback.ratelimit.fail-window-minutes:5}") + private Duration FAIL_COUNT_WINDOW; + private int FAILS_TO_BACKOFF = 3; + @Value("${platform.feedback.ratelimit.backoff-window-minutes:15}") + private Duration BACKOFF_WINDOW; + + public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis) { this.redis = redis; - this.pepper = env.getProperty("platform.feedback.pepper", ""); - this.trustForwardedFor = Boolean.parseBoolean(env.getProperty("platform.feedback.trust-forwarded-for", "true")); - this.forwardedForHeader = env.getProperty("platform.feedback.forwarded-for-header", "X-Forwarded-For"); } @Override diff --git a/src/main/java/com/iemr/common/utils/FilterConfig.java b/src/main/java/com/iemr/common/utils/FilterConfig.java index 8664199c..fd862262 100644 --- a/src/main/java/com/iemr/common/utils/FilterConfig.java +++ b/src/main/java/com/iemr/common/utils/FilterConfig.java @@ -40,6 +40,10 @@ public class FilterConfig { @Value("${cors.allowed-origins}") private String allowedOrigins; + @Value("${platform.feedback.ratelimit.enabled:false}") + private boolean enabled; + + @Bean public FilterRegistrationBean jwtUserIdValidationFilter( JwtAuthenticationUtil jwtAuthenticationUtil) { @@ -63,25 +67,13 @@ public FilterRegistrationBean jwtUserIdValidationFilt */ @Bean public FilterRegistrationBean platformFeedbackRateLimitFilter( - StringRedisTemplate stringRedisTemplate, - Environment env) { + StringRedisTemplate stringRedisTemplate) { - // Read flag from environment (property file or env var) - boolean enabled = Boolean.parseBoolean(env.getProperty("platform.feedback.ratelimit.enabled", "false")); // Allow optional override for order if needed - int defaultOrder = Ordered.HIGHEST_PRECEDENCE + 10; - int order = defaultOrder; - String orderStr = env.getProperty("platform.feedback.ratelimit.order"); - if (orderStr != null) { - try { - order = Integer.parseInt(orderStr); - } catch (NumberFormatException e) { - log.warn("Invalid platform.feedback.ratelimit.order value '{}', using default {}", orderStr, defaultOrder); - } - } - - PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate, env); + int order = Ordered.HIGHEST_PRECEDENCE + 10; + + PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate); FilterRegistrationBean reg = new FilterRegistrationBean<>(filter); From a97d332c45834e4f6c228febbf1063ac11ebf9c1 Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Thu, 18 Sep 2025 17:15:44 +0530 Subject: [PATCH 09/12] fix: dev-linux issue fix --- .../com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java index fcf286d1..1f94537c 100644 --- a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -70,7 +70,7 @@ public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { @Value("${platform.feedback.ratelimit.fail-window-minutes:5}") private Duration FAIL_COUNT_WINDOW; private int FAILS_TO_BACKOFF = 3; - @Value("${platform.feedback.ratelimit.backoff-window-minutes:15}") + @Value("${platform.feedback.ratelimit.backoff-minutes:15}") private Duration BACKOFF_WINDOW; public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis) { From 70695b7494d9b03d863d250630a30f1764f1421f Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Thu, 18 Sep 2025 17:44:33 +0530 Subject: [PATCH 10/12] fix: dev-linux issue fix --- .../PlatformFeedbackRateLimitFilter.java | 149 ++++++++++++------ 1 file changed, 98 insertions(+), 51 deletions(-) diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java index 1f94537c..be574ca0 100644 --- a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -21,10 +21,13 @@ */ package com.iemr.common.filter; +import jakarta.annotation.PostConstruct; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.data.redis.core.StringRedisTemplate; @@ -44,56 +47,83 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.beans.factory.annotation.Value; - @Component @ConditionalOnProperty(prefix = "platform.feedback.ratelimit", name = "enabled", havingValue = "true", matchIfMissing = false) -@Order(Ordered.HIGHEST_PRECEDENCE + 10) // run early (adjust order as needed) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { + private static final Logger log = LoggerFactory.getLogger(PlatformFeedbackRateLimitFilter.class); + private final StringRedisTemplate redis; - @Value("${platform.feedback.ratelimit.pepper}") + + @Value("${platform.feedback.ratelimit.pepper:}") private String pepper; + @Value("${platform.feedback.ratelimit.trust-forwarded-for:false}") private boolean trustForwardedFor; + @Value("${platform.feedback.ratelimit.forwarded-for-header:X-Forwarded-For}") private String forwardedForHeader; - // Limits & TTLs (tweak if needed) @Value("${platform.feedback.ratelimit.minute-limit:10}") - private int MINUTE_LIMIT; + private int minuteLimit; + @Value("${platform.feedback.ratelimit.day-limit:100}") - private int DAY_LIMIT; + private int dayLimit; + @Value("${platform.feedback.ratelimit.user-day-limit:50}") - private int USER_DAY_LIMIT; // for identified users - private Duration MINUTE_WINDOW = Duration.ofMinutes(1); - private Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h + private int userDayLimit; + + private final Duration MINUTE_WINDOW = Duration.ofMinutes(1); + private final Duration DAY_WINDOW = Duration.ofHours(48); + @Value("${platform.feedback.ratelimit.fail-window-minutes:5}") - private Duration FAIL_COUNT_WINDOW; - private int FAILS_TO_BACKOFF = 3; + private long failCountWindowMinutes; + @Value("${platform.feedback.ratelimit.backoff-minutes:15}") - private Duration BACKOFF_WINDOW; + private long backoffWindowMinutes; + + @Value("${platform.feedback.ratelimit.fails-to-backoff:3}") + private int failsToBackoff; public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis) { this.redis = redis; } + @PostConstruct + public void validateConfig() { + if (!StringUtils.hasText(pepper)) { + throw new IllegalStateException("platform.feedback.ratelimit.pepper must be set"); + } + if (failCountWindowMinutes <= 0) { + throw new IllegalStateException("platform.feedback.ratelimit.fail-window-minutes must be > 0"); + } + if (backoffWindowMinutes <= 0) { + throw new IllegalStateException("platform.feedback.ratelimit.backoff-minutes must be > 0"); + } + if (minuteLimit <= 0 || dayLimit <= 0 || userDayLimit <= 0) { + log.warn("One of the rate limits is non-positive; please check configuration"); + } + log.info("PlatformFeedbackRateLimitFilter initialized (minuteLimit={}, dayLimit={}, userDayLimit={}, failWindowMinutes={}, backoffMinutes={}, failsToBackoff={})", + minuteLimit, dayLimit, userDayLimit, failCountWindowMinutes, backoffWindowMinutes, failsToBackoff); + } + @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { // Only filter specific endpoints (POST to platform-feedback). Keep it narrow. String path = request.getRequestURI(); String method = request.getMethod(); - // adjust path as needed (supports /common-api/platform-feedback and subpaths) - return !("POST".equalsIgnoreCase(method) && path != null && path.matches("^/platform-feedback(?:/.*)?$")); + // Allow context path prefixes, e.g. /common-api/platform-feedback + return !("POST".equalsIgnoreCase(method) && path != null && path.matches(".*/platform-feedback(?:/.*)?$")); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // compute day key String clientIp = extractClientIp(request); if (clientIp == null || clientIp.isBlank()) { - // If we can't identify an IP, be conservative and allow but log (or optionally block) + log.debug("Client IP could not be determined; allowing request (fail-open). RequestURI={}", request.getRequestURI()); filterChain.doFilter(request, response); return; } @@ -105,68 +135,77 @@ protected void doFilterInternal(HttpServletRequest request, String failKey = "rl:fb:fail:" + ipHash; String backoffKey = "rl:fb:backoff:" + ipHash; - // If under backoff -> respond 429 with Retry-After = TTL Long backoffTtl = getTtlSeconds(backoffKey); if (backoffTtl != null && backoffTtl > 0) { + log.debug("IP in backoff (ipHash={}, ttl={})", ipHash, backoffTtl); sendTooMany(response, backoffTtl); return; } - // Minute window check (INCR + TTL if first) long minuteCount = incrementWithExpire(minKey, 1, MINUTE_WINDOW.getSeconds()); - if (minuteCount > MINUTE_LIMIT) { + if (minuteCount > minuteLimit) { + log.info("Minute limit hit for ipHash={} minuteCount={}", ipHash, minuteCount); handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); return; } - // Day window check long dayCount = incrementWithExpire(dayKey, 1, DAY_WINDOW.getSeconds()); - if (dayCount > DAY_LIMIT) { + if (dayCount > dayLimit) { + log.info("Day limit hit for ipHash={} dayCount={}", ipHash, dayCount); handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); return; } - // Optional: per-user daily cap if we can extract an authenticated user id from header/jwt Integer userId = extractUserIdFromRequest(request); // implement extraction as per your JWT scheme if (userId != null) { String userDayKey = "rl:fb:user:" + today + ":" + userId; long ucount = incrementWithExpire(userDayKey, 1, DAY_WINDOW.getSeconds()); - if (ucount > USER_DAY_LIMIT) { + if (ucount > userDayLimit) { + log.info("User day limit hit for userId={} count={}", userId, ucount); handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, userDayKey); return; } } - // All checks passed — proceed to controller filterChain.doFilter(request, response); } - // increments key by delta; sets TTL when key is new (INCR returns 1) private long incrementWithExpire(String key, long delta, long ttlSeconds) { - Long value = redis.opsForValue().increment(key, delta); - if (value != null && value == 1L) { - redis.expire(key, ttlSeconds, TimeUnit.SECONDS); + try { + Long value = redis.opsForValue().increment(key, delta); + if (value != null && value == 1L) { + redis.expire(key, ttlSeconds, TimeUnit.SECONDS); + } + return value == null ? 0L : value; + } catch (Exception ex) { + log.error("Redis increment failed for key={} delta={} - failing open (allow request). Exception: {}", key, delta, ex.toString()); + return 0L; } - return value == null ? 0L : value; } private void handleFailureAndMaybeBackoff(String failKey, String backoffKey, HttpServletResponse response, String trigKey, String dayKey) throws IOException { - // increment fail counter and possibly set backoff - Long fails = redis.opsForValue().increment(failKey, 1); - if (fails != null && fails == 1L) { - redis.expire(failKey, FAIL_COUNT_WINDOW.getSeconds(), TimeUnit.SECONDS); - } - if (fails != null && fails >= FAILS_TO_BACKOFF) { - // set backoff flag - redis.opsForValue().set(backoffKey, "1", BACKOFF_WINDOW.getSeconds(), TimeUnit.SECONDS); - sendTooMany(response, BACKOFF_WINDOW.getSeconds()); - return; - } + try { + Long fails = redis.opsForValue().increment(failKey, 1); + if (fails != null && fails == 1L) { + redis.expire(failKey, getFailCountWindowSeconds(), TimeUnit.SECONDS); + } + log.debug("Fail counter for key {} is {}", failKey, fails); + + if (fails != null && fails >= failsToBackoff) { + long backoffSeconds = getBackoffWindowSeconds(); + redis.opsForValue().set(backoffKey, "1", backoffSeconds, TimeUnit.SECONDS); + log.info("Entering backoff for ip (backoffKey={}, backoffSeconds={})", backoffKey, backoffSeconds); + sendTooMany(response, backoffSeconds); + return; + } - // otherwise respond with Retry-After for the triggering key TTL (minute/day) - Long retryAfter = getTtlSeconds(trigKey); - if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; - sendTooMany(response, retryAfter); + Long retryAfter = getTtlSeconds(trigKey); + if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; + log.debug("Responding rate-limited with Retry-After={} for key={}", retryAfter, trigKey); + sendTooMany(response, retryAfter); + } catch (Exception ex) { + log.error("Error while handling failure/backoff; failing open and allowing request. Exception: {}", ex.toString()); + } } private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) throws IOException { @@ -178,15 +217,19 @@ private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) t } private Long getTtlSeconds(String key) { - Long ttl = redis.getExpire(key, TimeUnit.SECONDS); - return ttl == null || ttl < 0 ? null : ttl; + try { + Long ttl = redis.getExpire(key, TimeUnit.SECONDS); + return ttl == null || ttl < 0 ? null : ttl; + } catch (Exception ex) { + log.warn("Redis getExpire failed for key={} - treating as no TTL. Exception: {}", key, ex.toString()); + return null; + } } private String extractClientIp(HttpServletRequest request) { if (trustForwardedFor) { String header = request.getHeader(forwardedForHeader); if (StringUtils.hasText(header)) { - // X-Forwarded-For may contain comma-separated list; take the first (client) entry String[] parts = header.split(","); if (parts.length > 0) { String ip = parts[0].trim(); @@ -198,21 +241,25 @@ private String extractClientIp(HttpServletRequest request) { } private Integer extractUserIdFromRequest(HttpServletRequest request) { - // implement based on how you propagate JWT or user info. - // Example: if your gateway injects header X-User-Id for authenticated requests: String s = request.getHeader("X-User-Id"); if (StringUtils.hasText(s)) { try { return Integer.valueOf(s); } catch (NumberFormatException ignored) {} } - // If JWT parsing required, do it here, but keep this filter light — prefer upstream auth filter to populate a header. return null; } + private long getFailCountWindowSeconds() { + return Duration.ofMinutes(failCountWindowMinutes).getSeconds(); + } + + private long getBackoffWindowSeconds() { + return Duration.ofMinutes(backoffWindowMinutes).getSeconds(); + } + private static String sha256Base64(String input) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8)); - // base64 url-safe or normal base64 — either is fine; base64 is shorter than hex return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); } catch (Exception ex) { throw new RuntimeException("sha256 failure", ex); From ae474a90d75ebfd9c6b9b66616c6f484e1238d40 Mon Sep 17 00:00:00 2001 From: 5Amogh Date: Fri, 19 Sep 2025 08:51:55 +0530 Subject: [PATCH 11/12] fix: dev-linux issue fix --- .../PlatformFeedbackRateLimitFilter.java | 174 ++++++------------ .../com/iemr/common/utils/FilterConfig.java | 26 ++- 2 files changed, 78 insertions(+), 122 deletions(-) diff --git a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java index be574ca0..d6dddf55 100644 --- a/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java +++ b/src/main/java/com/iemr/common/filter/PlatformFeedbackRateLimitFilter.java @@ -21,13 +21,10 @@ */ package com.iemr.common.filter; -import jakarta.annotation.PostConstruct; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.data.redis.core.StringRedisTemplate; @@ -45,67 +42,34 @@ import java.util.Base64; import java.util.concurrent.TimeUnit; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.beans.factory.annotation.Value; + @Component @ConditionalOnProperty(prefix = "platform.feedback.ratelimit", name = "enabled", havingValue = "true", matchIfMissing = false) -@Order(Ordered.HIGHEST_PRECEDENCE + 10) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) // run early (adjust order as needed) public class PlatformFeedbackRateLimitFilter extends OncePerRequestFilter { - private static final Logger log = LoggerFactory.getLogger(PlatformFeedbackRateLimitFilter.class); - private final StringRedisTemplate redis; - - @Value("${platform.feedback.ratelimit.pepper:}") - private String pepper; - - @Value("${platform.feedback.ratelimit.trust-forwarded-for:false}") - private boolean trustForwardedFor; - - @Value("${platform.feedback.ratelimit.forwarded-for-header:X-Forwarded-For}") - private String forwardedForHeader; - - @Value("${platform.feedback.ratelimit.minute-limit:10}") - private int minuteLimit; - - @Value("${platform.feedback.ratelimit.day-limit:100}") - private int dayLimit; - - @Value("${platform.feedback.ratelimit.user-day-limit:50}") - private int userDayLimit; - - private final Duration MINUTE_WINDOW = Duration.ofMinutes(1); - private final Duration DAY_WINDOW = Duration.ofHours(48); - - @Value("${platform.feedback.ratelimit.fail-window-minutes:5}") - private long failCountWindowMinutes; - - @Value("${platform.feedback.ratelimit.backoff-minutes:15}") - private long backoffWindowMinutes; - - @Value("${platform.feedback.ratelimit.fails-to-backoff:3}") - private int failsToBackoff; - - public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis) { + private final String pepper; + private final boolean trustForwardedFor; + private final String forwardedForHeader; + + // Limits & TTLs (tweak if needed) + private static final int MINUTE_LIMIT = 10; + private static final int DAY_LIMIT = 100; + private static final int USER_DAY_LIMIT = 50; // for identified users + private static final Duration MINUTE_WINDOW = Duration.ofMinutes(1); + private static final Duration DAY_WINDOW = Duration.ofHours(48); // keep key TTL ~48h + private static final Duration FAIL_COUNT_WINDOW = Duration.ofMinutes(5); + private static final int FAILS_TO_BACKOFF = 3; + private static final Duration BACKOFF_WINDOW = Duration.ofMinutes(15); + + public PlatformFeedbackRateLimitFilter(StringRedisTemplate redis, + org.springframework.core.env.Environment env) { this.redis = redis; - } - - @PostConstruct - public void validateConfig() { - if (!StringUtils.hasText(pepper)) { - throw new IllegalStateException("platform.feedback.ratelimit.pepper must be set"); - } - if (failCountWindowMinutes <= 0) { - throw new IllegalStateException("platform.feedback.ratelimit.fail-window-minutes must be > 0"); - } - if (backoffWindowMinutes <= 0) { - throw new IllegalStateException("platform.feedback.ratelimit.backoff-minutes must be > 0"); - } - if (minuteLimit <= 0 || dayLimit <= 0 || userDayLimit <= 0) { - log.warn("One of the rate limits is non-positive; please check configuration"); - } - log.info("PlatformFeedbackRateLimitFilter initialized (minuteLimit={}, dayLimit={}, userDayLimit={}, failWindowMinutes={}, backoffMinutes={}, failsToBackoff={})", - minuteLimit, dayLimit, userDayLimit, failCountWindowMinutes, backoffWindowMinutes, failsToBackoff); + this.pepper = env.getProperty("platform.feedback.pepper", ""); + this.trustForwardedFor = Boolean.parseBoolean(env.getProperty("platform.feedback.trust-forwarded-for", "true")); + this.forwardedForHeader = env.getProperty("platform.feedback.forwarded-for-header", "X-Forwarded-For"); } @Override @@ -113,17 +77,18 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce // Only filter specific endpoints (POST to platform-feedback). Keep it narrow. String path = request.getRequestURI(); String method = request.getMethod(); - // Allow context path prefixes, e.g. /common-api/platform-feedback - return !("POST".equalsIgnoreCase(method) && path != null && path.matches(".*/platform-feedback(?:/.*)?$")); + // adjust path as needed (supports /common-api/platform-feedback and subpaths) + return !("POST".equalsIgnoreCase(method) && path != null && path.matches("^/platform-feedback(?:/.*)?$")); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // compute day key String clientIp = extractClientIp(request); if (clientIp == null || clientIp.isBlank()) { - log.debug("Client IP could not be determined; allowing request (fail-open). RequestURI={}", request.getRequestURI()); + // If we can't identify an IP, be conservative and allow but log (or optionally block) filterChain.doFilter(request, response); return; } @@ -135,77 +100,68 @@ protected void doFilterInternal(HttpServletRequest request, String failKey = "rl:fb:fail:" + ipHash; String backoffKey = "rl:fb:backoff:" + ipHash; + // If under backoff -> respond 429 with Retry-After = TTL Long backoffTtl = getTtlSeconds(backoffKey); if (backoffTtl != null && backoffTtl > 0) { - log.debug("IP in backoff (ipHash={}, ttl={})", ipHash, backoffTtl); sendTooMany(response, backoffTtl); return; } + // Minute window check (INCR + TTL if first) long minuteCount = incrementWithExpire(minKey, 1, MINUTE_WINDOW.getSeconds()); - if (minuteCount > minuteLimit) { - log.info("Minute limit hit for ipHash={} minuteCount={}", ipHash, minuteCount); + if (minuteCount > MINUTE_LIMIT) { handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); return; } + // Day window check long dayCount = incrementWithExpire(dayKey, 1, DAY_WINDOW.getSeconds()); - if (dayCount > dayLimit) { - log.info("Day limit hit for ipHash={} dayCount={}", ipHash, dayCount); + if (dayCount > DAY_LIMIT) { handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, dayKey); return; } + // Optional: per-user daily cap if we can extract an authenticated user id from header/jwt Integer userId = extractUserIdFromRequest(request); // implement extraction as per your JWT scheme if (userId != null) { String userDayKey = "rl:fb:user:" + today + ":" + userId; long ucount = incrementWithExpire(userDayKey, 1, DAY_WINDOW.getSeconds()); - if (ucount > userDayLimit) { - log.info("User day limit hit for userId={} count={}", userId, ucount); + if (ucount > USER_DAY_LIMIT) { handleFailureAndMaybeBackoff(failKey, backoffKey, response, minKey, userDayKey); return; } } + // All checks passed — proceed to controller filterChain.doFilter(request, response); } + // increments key by delta; sets TTL when key is new (INCR returns 1) private long incrementWithExpire(String key, long delta, long ttlSeconds) { - try { - Long value = redis.opsForValue().increment(key, delta); - if (value != null && value == 1L) { - redis.expire(key, ttlSeconds, TimeUnit.SECONDS); - } - return value == null ? 0L : value; - } catch (Exception ex) { - log.error("Redis increment failed for key={} delta={} - failing open (allow request). Exception: {}", key, delta, ex.toString()); - return 0L; + Long value = redis.opsForValue().increment(key, delta); + if (value != null && value == 1L) { + redis.expire(key, ttlSeconds, TimeUnit.SECONDS); } + return value == null ? 0L : value; } private void handleFailureAndMaybeBackoff(String failKey, String backoffKey, HttpServletResponse response, String trigKey, String dayKey) throws IOException { - try { - Long fails = redis.opsForValue().increment(failKey, 1); - if (fails != null && fails == 1L) { - redis.expire(failKey, getFailCountWindowSeconds(), TimeUnit.SECONDS); - } - log.debug("Fail counter for key {} is {}", failKey, fails); - - if (fails != null && fails >= failsToBackoff) { - long backoffSeconds = getBackoffWindowSeconds(); - redis.opsForValue().set(backoffKey, "1", backoffSeconds, TimeUnit.SECONDS); - log.info("Entering backoff for ip (backoffKey={}, backoffSeconds={})", backoffKey, backoffSeconds); - sendTooMany(response, backoffSeconds); - return; - } - - Long retryAfter = getTtlSeconds(trigKey); - if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; - log.debug("Responding rate-limited with Retry-After={} for key={}", retryAfter, trigKey); - sendTooMany(response, retryAfter); - } catch (Exception ex) { - log.error("Error while handling failure/backoff; failing open and allowing request. Exception: {}", ex.toString()); + // increment fail counter and possibly set backoff + Long fails = redis.opsForValue().increment(failKey, 1); + if (fails != null && fails == 1L) { + redis.expire(failKey, FAIL_COUNT_WINDOW.getSeconds(), TimeUnit.SECONDS); } + if (fails != null && fails >= FAILS_TO_BACKOFF) { + // set backoff flag + redis.opsForValue().set(backoffKey, "1", BACKOFF_WINDOW.getSeconds(), TimeUnit.SECONDS); + sendTooMany(response, BACKOFF_WINDOW.getSeconds()); + return; + } + + // otherwise respond with Retry-After for the triggering key TTL (minute/day) + Long retryAfter = getTtlSeconds(trigKey); + if (retryAfter == null || retryAfter <= 0) retryAfter = 60L; + sendTooMany(response, retryAfter); } private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) throws IOException { @@ -217,19 +173,15 @@ private void sendTooMany(HttpServletResponse response, long retryAfterSeconds) t } private Long getTtlSeconds(String key) { - try { - Long ttl = redis.getExpire(key, TimeUnit.SECONDS); - return ttl == null || ttl < 0 ? null : ttl; - } catch (Exception ex) { - log.warn("Redis getExpire failed for key={} - treating as no TTL. Exception: {}", key, ex.toString()); - return null; - } + Long ttl = redis.getExpire(key, TimeUnit.SECONDS); + return ttl == null || ttl < 0 ? null : ttl; } private String extractClientIp(HttpServletRequest request) { if (trustForwardedFor) { String header = request.getHeader(forwardedForHeader); if (StringUtils.hasText(header)) { + // X-Forwarded-For may contain comma-separated list; take the first (client) entry String[] parts = header.split(","); if (parts.length > 0) { String ip = parts[0].trim(); @@ -241,28 +193,24 @@ private String extractClientIp(HttpServletRequest request) { } private Integer extractUserIdFromRequest(HttpServletRequest request) { + // implement based on how you propagate JWT or user info. + // Example: if your gateway injects header X-User-Id for authenticated requests: String s = request.getHeader("X-User-Id"); if (StringUtils.hasText(s)) { try { return Integer.valueOf(s); } catch (NumberFormatException ignored) {} } + // If JWT parsing required, do it here, but keep this filter light — prefer upstream auth filter to populate a header. return null; } - private long getFailCountWindowSeconds() { - return Duration.ofMinutes(failCountWindowMinutes).getSeconds(); - } - - private long getBackoffWindowSeconds() { - return Duration.ofMinutes(backoffWindowMinutes).getSeconds(); - } - private static String sha256Base64(String input) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + // base64 url-safe or normal base64 — either is fine; base64 is shorter than hex return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); } catch (Exception ex) { throw new RuntimeException("sha256 failure", ex); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/iemr/common/utils/FilterConfig.java b/src/main/java/com/iemr/common/utils/FilterConfig.java index fd862262..42bd04ad 100644 --- a/src/main/java/com/iemr/common/utils/FilterConfig.java +++ b/src/main/java/com/iemr/common/utils/FilterConfig.java @@ -40,10 +40,6 @@ public class FilterConfig { @Value("${cors.allowed-origins}") private String allowedOrigins; - @Value("${platform.feedback.ratelimit.enabled:false}") - private boolean enabled; - - @Bean public FilterRegistrationBean jwtUserIdValidationFilter( JwtAuthenticationUtil jwtAuthenticationUtil) { @@ -67,13 +63,25 @@ public FilterRegistrationBean jwtUserIdValidationFilt */ @Bean public FilterRegistrationBean platformFeedbackRateLimitFilter( - StringRedisTemplate stringRedisTemplate) { + StringRedisTemplate stringRedisTemplate, + Environment env) { + // Read flag from environment (property file or env var) + boolean enabled = Boolean.parseBoolean(env.getProperty("platform.feedback.ratelimit.enabled", "false")); // Allow optional override for order if needed - int order = Ordered.HIGHEST_PRECEDENCE + 10; - - PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate); + int defaultOrder = Ordered.HIGHEST_PRECEDENCE + 10; + int order = defaultOrder; + String orderStr = env.getProperty("platform.feedback.ratelimit.order"); + if (orderStr != null) { + try { + order = Integer.parseInt(orderStr); + } catch (NumberFormatException e) { + log.warn("Invalid platform.feedback.ratelimit.order value '{}', using default {}", orderStr, defaultOrder); + } + } + + PlatformFeedbackRateLimitFilter filter = new PlatformFeedbackRateLimitFilter(stringRedisTemplate, env); FilterRegistrationBean reg = new FilterRegistrationBean<>(filter); @@ -91,4 +99,4 @@ public FilterRegistrationBean platformFeedbackR return reg; } -} +} \ No newline at end of file From aa94a5a219404c002ea107e83366d9de4cde0933 Mon Sep 17 00:00:00 2001 From: Amoghavarsh <93114621+5Amogh@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:39:12 +0530 Subject: [PATCH 12/12] Update pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a2e6e019..159a28ad 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.iemr.common-API common-api - 3.5.0 + 3.8.0 war Common-API