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..b499ef3a --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/Feedback.java @@ -0,0 +1,171 @@ +/* + * 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.Size; +import java.time.LocalDateTime; +import java.util.UUID; + +@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, insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime updatedAt; + + @Min(1) + @Max(5) + @Column(name = "Rating", nullable = false) + private int rating; + + @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 = true; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "CategoryID", referencedColumnName = "CategoryID", nullable = false) + private FeedbackCategory category; + + @Column(name = "UserID", nullable = true) + private Integer userId; + + public Feedback() { + this.feedbackId = UUID.randomUUID().toString(); + } + + public Feedback(int rating, String comment, String serviceLine, boolean isAnonymous, FeedbackCategory category, Integer userId) { + this(); // ensures feedbackId + this.setRating(rating); + this.setComment(comment); + this.setServiceLine(serviceLine); + this.isAnonymous = isAnonymous; + this.category = category; + this.userId = userId; + } + + 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; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + 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.length() > 2000) { + throw new IllegalArgumentException("Comment cannot exceed 2000 characters"); + } + this.comment = (comment == null || comment.trim().isEmpty()) ? null : comment.trim(); + } + + 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; + if (anonymous) { + this.userId = null; + } + } + + 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; + if (userId != null) { + this.isAnonymous = false; + } + } +} 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..ee9ffffe --- /dev/null +++ b/src/main/java/com/iemr/common/data/platform_feedback/FeedbackCategory.java @@ -0,0 +1,153 @@ +/* + * 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 = true; + + + @Column(name = "CreatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "UpdatedAt", nullable = false, insertable = false, updatable = false) + private LocalDateTime updatedAt; + + // ===== Constructors ===== + public FeedbackCategory() { + // nothing — categoryId will be generated at persist if not provided + } + + 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 ===== + /** + * 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 ensureId() { + if (this.categoryId == null || this.categoryId.trim().isEmpty()) { + this.categoryId = UUID.randomUUID().toString(); + } + } + + // ===== 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 DB; setter available for tests/migrations + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + // UpdatedAt normally managed by DB + 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");