From 5833c2980a75fa6a174ab5ec70478075a6bc7de4 Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:10:01 +0530 Subject: [PATCH 1/7] bug fix/ missing endpoints added /me and /{id} were missing --- .../controller/AppointmentController.java | 23 ++++++++++++++++ .../backend/controller/AuthController.java | 27 +++++++++++++++++++ .../controller/LabResultController.java | 7 +++++ .../controller/MedicalRecordController.java | 7 +++++ .../controller/PrescriptionController.java | 7 +++++ 5 files changed, 71 insertions(+) diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java index 435c7fe4..c3743c6b 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java @@ -120,6 +120,29 @@ public ResponseEntity getAllAppointments(Authentication auth) { return ResponseEntity.ok(appointmentService.getAllAppointments()); } + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id, Authentication auth) { + return appointmentRepository.findById(id) + .map(appt -> ResponseEntity.ok((Object) appt)) + .orElse(ResponseEntity.status(404).body("Appointment not found with id: " + id)); + } + + @GetMapping("/status/{status}") + public ResponseEntity> getByStatus(@PathVariable String status) { + return ResponseEntity.ok(appointmentRepository.findByStatus(status)); + } + + @GetMapping("/stats") + public ResponseEntity getStats() { + java.util.Map stats = new java.util.HashMap<>(); + stats.put("total", appointmentRepository.count()); + stats.put("pending", appointmentRepository.countByStatus("PENDING")); + stats.put("scheduled", appointmentRepository.countByStatus("SCHEDULED")); + stats.put("completed", appointmentRepository.countByStatus("COMPLETED")); + stats.put("cancelled", appointmentRepository.countByStatus("CANCELLED")); + return ResponseEntity.ok(stats); + } + @PutMapping("/{id}/complete") public ResponseEntity completeAppointment(@PathVariable Long id, Authentication auth) { // Enforce RBAC: Only Doctors can mark as complete diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/AuthController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/AuthController.java index 3d8db8e1..cf6607e4 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/AuthController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/AuthController.java @@ -6,6 +6,7 @@ import com.securehealth.backend.dto.RegistrationRequest; import com.securehealth.backend.dto.ResetPasswordRequest; import com.securehealth.backend.model.Login; +import com.securehealth.backend.repository.LoginRepository; import com.securehealth.backend.service.AuthService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; @@ -36,6 +37,32 @@ public class AuthController { @Autowired private AuthService authService; + @Autowired + private LoginRepository loginRepository; + + /** + * Returns the current authenticated user's profile. + * Endpoint: GET /api/auth/me + */ + @GetMapping("/me") + public ResponseEntity getCurrentUser(org.springframework.security.core.Authentication authentication) { + if (authentication == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Not authenticated")); + } + String email = authentication.getName(); + return loginRepository.findByEmail(email) + .map(user -> { + Map profile = new HashMap<>(); + profile.put("id", user.getUserId()); + profile.put("email", user.getEmail()); + profile.put("role", user.getRole().name()); + profile.put("twoFactorEnabled", user.isTwoFactorEnabled()); + profile.put("createdAt", user.getCreatedAt()); + return ResponseEntity.ok(profile); + }) + .orElse(ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "User not found"))); + } + /** * Register a new user. *

diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java index 5b96b130..4095ac44 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java @@ -27,6 +27,13 @@ public ResponseEntity> getByPatient(@PathVariable Long patientI return ResponseEntity.ok(labTestService.getLabTestsByPatient(patientId)); } + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id, Authentication auth) { + return labTestRepository.findById(id) + .map(test -> ResponseEntity.ok((Object) test)) + .orElse(ResponseEntity.status(404).body("Lab result not found with id: " + id)); + } + @PostMapping diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java index da57f6b6..228b828e 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java @@ -27,6 +27,13 @@ public ResponseEntity> getByPatient(@PathVariable Long pa return ResponseEntity.ok(medicalRecordService.getMedicalRecordsByPatient(patientId)); } + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id, Authentication auth) { + return medicalRecordRepository.findById(id) + .map(record -> ResponseEntity.ok((Object) record)) + .orElse(ResponseEntity.status(404).body("Medical record not found with id: " + id)); + } + @PostMapping public ResponseEntity createMedicalRecord(@RequestBody com.securehealth.backend.dto.MedicalRecordRequest request, Authentication auth) { // Enforce RBAC: Only Doctors can create medical records diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java index 1c09ac89..db10ce84 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java @@ -27,6 +27,13 @@ public ResponseEntity> getByPatient(@PathVariable Long pat return ResponseEntity.ok(prescriptionService.getPrescriptionsByPatient(patientId)); } + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id, Authentication auth) { + return prescriptionRepository.findById(id) + .map(rx -> ResponseEntity.ok((Object) rx)) + .orElse(ResponseEntity.status(404).body("Prescription not found with id: " + id)); + } + @PostMapping public ResponseEntity createPrescription(@RequestBody com.securehealth.backend.dto.PrescriptionRequest request, Authentication auth) { // Enforce RBAC: Only Doctors can write prescriptions From 04b9206f9e26a14e404ffff6a6848920091e939e Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:44:39 +0530 Subject: [PATCH 2/7] bug fix/ manual RBAC to Spring Security + cors in application properties for deployment --- .../backend/config/SecurityConfig.java | 7 ++- .../controller/AppointmentController.java | 56 +++---------------- .../controller/LabResultController.java | 9 +-- .../controller/MedicalRecordController.java | 9 +-- .../controller/PrescriptionController.java | 9 +-- .../src/main/resources/application.properties | 13 +++-- 6 files changed, 29 insertions(+), 74 deletions(-) diff --git a/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java b/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java index d847f7ed..d69daad0 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java @@ -16,6 +16,8 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.beans.factory.annotation.Value; +import java.util.Arrays; import java.util.List; @Configuration @@ -27,6 +29,9 @@ public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; + @Value("${app.cors.allowed-origins:http://localhost:3000,http://127.0.0.1:3000}") + private String allowedOrigins; + @Bean public PasswordEncoder passwordEncoder() { return new Argon2PasswordEncoder(16, 32, 1, 4096, 3); @@ -62,7 +67,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://127.0.0.1:3000")); + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); configuration.setAllowCredentials(true); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java index c3743c6b..b94a6645 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java @@ -10,8 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -34,16 +34,6 @@ public ResponseEntity> getByPatient(@PathVariable Long pati @GetMapping("/doctor/{doctorId}") public ResponseEntity> getByDoctor(@PathVariable Long doctorId, Authentication auth) { - // Strict Security Check: Ensure the logged-in doctor is checking their OWN schedule - String email = auth.getName(); - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - - // This assumes your Appointment entity links to a Doctor Login with that ID - if (!role.equals("ADMIN") && !email.equals(appointmentRepository.findById(doctorId).map(a -> a.getDoctor().getEmail()).orElse(""))) { - // In a production app, you'd check against the DoctorProfile, but this gives the idea! - // Let's actually keep it simple: doctors can view their own schedule by their user ID. - } - return ResponseEntity.ok(appointmentRepository.findByDoctor_UserIdOrderByAppointmentDateAsc(doctorId)); } @@ -77,13 +67,8 @@ public ResponseEntity createAppointment(@RequestBody AppointmentRequest reque } @PutMapping("/{id}/approve") - public ResponseEntity approveAppointment(@PathVariable Long id, Authentication auth) { - // Enforce strict Role-Based Access Control (RBAC) - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("ADMIN")) { - return ResponseEntity.status(403).body("Forbidden: Only administrative staff can approve appointments."); - } - + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity approveAppointment(@PathVariable Long id) { try { Appointment approved = appointmentService.approveAppointment(id); return ResponseEntity.ok(approved); @@ -93,13 +78,8 @@ public ResponseEntity approveAppointment(@PathVariable Long id, Authenticatio } @PutMapping("/{id}/reject") - public ResponseEntity rejectAppointment(@PathVariable Long id, Authentication auth, @RequestBody(required = false) String reason) { - // Enforce strict Role-Based Access Control (RBAC) - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("ADMIN")) { - return ResponseEntity.status(403).body("Forbidden: Only administrative staff can reject appointments."); - } - + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity rejectAppointment(@PathVariable Long id, @RequestBody(required = false) String reason) { try { Appointment rejected = appointmentService.rejectAppointment(id, reason); return ResponseEntity.ok(rejected); @@ -109,14 +89,8 @@ public ResponseEntity rejectAppointment(@PathVariable Long id, Authentication } @GetMapping - public ResponseEntity getAllAppointments(Authentication auth) { - // Enforce RBAC: Block Patients - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (role.equals("PATIENT")) { - return ResponseEntity.status(403).body("Forbidden: Patients cannot view the global clinic schedule."); - } - - // Allow ADMIN and DOCTOR + @PreAuthorize("hasAnyAuthority('ADMIN', 'DOCTOR')") + public ResponseEntity getAllAppointments() { return ResponseEntity.ok(appointmentService.getAllAppointments()); } @@ -144,15 +118,9 @@ public ResponseEntity getStats() { } @PutMapping("/{id}/complete") + @PreAuthorize("hasAuthority('DOCTOR')") public ResponseEntity completeAppointment(@PathVariable Long id, Authentication auth) { - // Enforce RBAC: Only Doctors can mark as complete - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("DOCTOR")) { - return ResponseEntity.status(403).body("Forbidden: Only Doctors can complete appointments."); - } - try { - // auth.getName() safely gets the logged-in doctor's email from the JWT return ResponseEntity.ok(appointmentService.completeAppointment(id, auth.getName())); } catch (RuntimeException e) { return ResponseEntity.status(400).body(e.getMessage()); @@ -160,17 +128,11 @@ public ResponseEntity completeAppointment(@PathVariable Long id, Authenticati } @PutMapping("/{id}") + @PreAuthorize("hasAuthority('DOCTOR')") public ResponseEntity updateAppointment( @PathVariable Long id, @RequestBody com.securehealth.backend.dto.AppointmentDTO request, Authentication auth) { - - // Enforce RBAC: Only Doctors can update - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("DOCTOR")) { - return ResponseEntity.status(403).body("Forbidden: Only Doctors can modify appointments."); - } - try { return ResponseEntity.ok(appointmentService.updateAppointment(id, request, auth.getName())); } catch (RuntimeException e) { diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java index 4095ac44..5eb33e10 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java @@ -7,8 +7,8 @@ import com.securehealth.backend.service.LabTestService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -37,13 +37,8 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { @PostMapping + @PreAuthorize("hasAnyAuthority('DOCTOR', 'ADMIN', 'LAB_TECHNICIAN')") public ResponseEntity createLabTest(@RequestBody com.securehealth.backend.dto.LabTestRequest request, Authentication auth) { - // Allow DOCTOR or ADMIN to add lab results - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("DOCTOR") && !role.equals("ADMIN") && !role.equals("LAB_TECHNICIAN")) { - return ResponseEntity.status(403).body("Forbidden: Insufficient privileges to add lab results."); - } - try { LabTest newLabTest = labTestService.createLabTest(request, auth.getName()); return ResponseEntity.ok(newLabTest); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java index 228b828e..594f16b6 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java @@ -7,8 +7,8 @@ import com.securehealth.backend.security.PatientAccessValidator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -35,13 +35,8 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { } @PostMapping + @PreAuthorize("hasAuthority('DOCTOR')") public ResponseEntity createMedicalRecord(@RequestBody com.securehealth.backend.dto.MedicalRecordRequest request, Authentication auth) { - // Enforce RBAC: Only Doctors can create medical records - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("DOCTOR")) { - return ResponseEntity.status(403).body("Forbidden: Only doctors can create medical records."); - } - try { MedicalRecord newRecord = medicalRecordService.createMedicalRecord(request, auth.getName()); return ResponseEntity.ok(newRecord); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java index db10ce84..bcb99a39 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java @@ -7,8 +7,8 @@ import com.securehealth.backend.security.PatientAccessValidator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -35,13 +35,8 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { } @PostMapping + @PreAuthorize("hasAuthority('DOCTOR')") public ResponseEntity createPrescription(@RequestBody com.securehealth.backend.dto.PrescriptionRequest request, Authentication auth) { - // Enforce RBAC: Only Doctors can write prescriptions - String role = auth.getAuthorities().stream().findFirst().map(GrantedAuthority::getAuthority).orElse(""); - if (!role.equals("DOCTOR")) { - return ResponseEntity.status(403).body("Forbidden: Only doctors can write prescriptions."); - } - try { Prescription newPrescription = prescriptionService.createPrescription(request, auth.getName()); return ResponseEntity.ok(newPrescription); diff --git a/backend/Backend/src/main/resources/application.properties b/backend/Backend/src/main/resources/application.properties index 893c8f55..7e0c8806 100644 --- a/backend/Backend/src/main/resources/application.properties +++ b/backend/Backend/src/main/resources/application.properties @@ -6,9 +6,9 @@ spring.datasource.username=${SPRING_DATASOURCE_USERNAME:temp_user} spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:temp_pass} spring.datasource.driver-class-name=org.postgresql.Driver -# Hibernate Settings (Auto-Create Tables) -spring.jpa.hibernate.ddl-auto=update -spring.jpa.show-sql=true +# Hibernate Settings +spring.jpa.hibernate.ddl-auto=${HIBERNATE_DDL_AUTO:update} +spring.jpa.show-sql=${JPA_SHOW_SQL:true} spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect # For sending otp through mail @@ -24,7 +24,10 @@ jwt.expiration=900000 # Frontend URL for password reset links app.frontend.url=${FRONTEND_URL:http://localhost:3000} -server.port=8080 +server.port=${SERVER_PORT:8080} + +# CORS Configuration +app.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://127.0.0.1:3000} spring.mail.properties.mail.smtp.ssl.trust=* @@ -34,4 +37,4 @@ spring.data.redis.port=6379 # If using Docker networking, use the container name: # spring.data.redis.host=redis -logging.level.org.springframework.security=TRACE \ No newline at end of file +logging.level.org.springframework.security=${SECURITY_LOG_LEVEL:TRACE} \ No newline at end of file From bb097ad76896b34eb869b6cc9e4f2b742f4fafbc Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:56:27 +0530 Subject: [PATCH 3/7] Global Exception Handler to replace all other exceptions --- .../backend/dto/ErrorResponse.java | 32 +++++ .../exception/GlobalExceptionHandler.java | 128 +++++++++++++++--- 2 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/dto/ErrorResponse.java diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/ErrorResponse.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/ErrorResponse.java new file mode 100644 index 00000000..60574c82 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/ErrorResponse.java @@ -0,0 +1,32 @@ +package com.securehealth.backend.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Standardized error response body for all API errors. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + + private int status; + private String error; + private String message; + private LocalDateTime timestamp; + private List details; + + public ErrorResponse(int status, String error, String message) { + this.status = status; + this.error = error; + this.message = message; + this.timestamp = LocalDateTime.now(); + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java b/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java index ee4483c5..e5c18704 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java @@ -1,35 +1,119 @@ package com.securehealth.backend.exception; +import com.securehealth.backend.dto.ErrorResponse; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.SignatureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.HashMap; -import java.util.Map; +import java.util.List; +import java.util.stream.Collectors; -@ControllerAdvice +/** + * Global Exception Handler for the entire backend. + * Catches exceptions thrown from any controller and returns + * a standardized JSON {@link ErrorResponse}. + */ +@RestControllerAdvice public class GlobalExceptionHandler { + // --- Validation Errors (e.g., @Valid on DTOs) --- + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List details = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.toList()); + + ErrorResponse error = new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Validation Failed", + "One or more fields are invalid."); + error.setDetails(details); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + // --- Access Denied (Spring Security @PreAuthorize failures) --- + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.FORBIDDEN.value(), + "Forbidden", + "You do not have permission to perform this action."); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + + // --- JWT: Expired Token --- + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity handleExpiredJwt(ExpiredJwtException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + "Token Expired", + "Your session has expired. Please log in again."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + // --- JWT: Malformed or Invalid Signature --- + @ExceptionHandler({MalformedJwtException.class, SignatureException.class}) + public ResponseEntity handleBadJwt(Exception ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + "Invalid Token", + "The provided authentication token is invalid."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + // --- Illegal Argument (e.g., bad enum values, parsing errors) --- + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + // --- General RuntimeException (catch-all for business logic errors) --- @ExceptionHandler(RuntimeException.class) - public ResponseEntity> handleRuntimeException(RuntimeException ex) { - Map response = new HashMap<>(); - response.put("error", ex.getMessage()); - - HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; - - if (ex.getMessage() != null) { - if (ex.getMessage().contains("404")) { - status = HttpStatus.NOT_FOUND; - } else if (ex.getMessage().contains("400")) { - status = HttpStatus.BAD_REQUEST; - } else if (ex.getMessage().contains("403")) { - status = HttpStatus.FORBIDDEN; - } else if (ex.getMessage().contains("401")) { - status = HttpStatus.UNAUTHORIZED; - } + public ResponseEntity handleRuntime(RuntimeException ex) { + String message = ex.getMessage() != null ? ex.getMessage() : "An unexpected error occurred."; + + if (message.toLowerCase().contains("not found")) { + ErrorResponse error = new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + "Not Found", + message); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } - - return new ResponseEntity<>(response, status); + + if (message.contains("409")) { + ErrorResponse error = new ErrorResponse( + HttpStatus.CONFLICT.value(), + "Conflict", + message); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + + ErrorResponse error = new ErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + // --- Ultimate fallback for truly unexpected errors --- + @ExceptionHandler(Exception.class) + public ResponseEntity handleAll(Exception ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal Server Error", + "An unexpected error occurred. Please try again later."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } } From 96737229092ae7ec780c16e428146d5266dfc32e Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:07:40 +0530 Subject: [PATCH 4/7] Added Consent Management GET /api/consent List all my consents POST /api/consent Grant consent to a provider PUT /api/consent/{id}/revoke Revoke a consent --- .../backend/controller/ConsentController.java | 47 ++++++++ .../securehealth/backend/model/Consent.java | 59 ++++++++++ .../backend/repository/ConsentRepository.java | 24 ++++ .../backend/service/ConsentService.java | 110 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/controller/ConsentController.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/model/Consent.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/repository/ConsentRepository.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/service/ConsentService.java diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/ConsentController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/ConsentController.java new file mode 100644 index 00000000..617539ac --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/ConsentController.java @@ -0,0 +1,47 @@ +package com.securehealth.backend.controller; + +import com.securehealth.backend.model.Consent; +import com.securehealth.backend.service.ConsentService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/consent") +@PreAuthorize("hasAuthority('PATIENT')") +public class ConsentController { + + @Autowired + private ConsentService consentService; + + /** + * GET /api/consent — List all consents for the logged-in patient. + */ + @GetMapping + public ResponseEntity getMyConsents(Authentication authentication) { + return ResponseEntity.ok(consentService.getMyConsents(authentication.getName())); + } + + /** + * POST /api/consent — Grant a new consent. + * Body: { "grantedToId": 5, "consentType": "MEDICAL_RECORDS", "reason": "...", "expiresAt": "..." } + */ + @PostMapping + public ResponseEntity grantConsent(@RequestBody Map payload, Authentication authentication) { + Consent consent = consentService.grantConsent(authentication.getName(), payload); + return ResponseEntity.status(201).body(consent); + } + + /** + * PUT /api/consent/{id}/revoke — Revoke a consent. + */ + @PutMapping("/{id}/revoke") + public ResponseEntity revokeConsent(@PathVariable Long id, Authentication authentication) { + Consent consent = consentService.revokeConsent(authentication.getName(), id); + return ResponseEntity.ok(consent); + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/Consent.java b/backend/Backend/src/main/java/com/securehealth/backend/model/Consent.java new file mode 100644 index 00000000..c8fdb681 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/Consent.java @@ -0,0 +1,59 @@ +package com.securehealth.backend.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Represents a patient's consent record for data sharing. + * A patient can grant or revoke access to specific data categories + * for individual healthcare providers (doctors, nurses, lab techs). + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "patient_consents") +public class Consent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // The patient who is granting consent + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "patient_id", referencedColumnName = "profileId", nullable = false) + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler", "user", "assignedDoctor", "assignedNurse"}) + private PatientProfile patient; + + // The provider who receives access + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "granted_to_id", referencedColumnName = "userId", nullable = false) + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler", "passwordHash", "otp", "otpExpiry"}) + private Login grantedTo; + + // Data category: MEDICAL_RECORDS, LAB_RESULTS, PRESCRIPTIONS, VITAL_SIGNS, ALL + @Column(nullable = false) + private String consentType; + + // ACTIVE or REVOKED + @Column(nullable = false) + private String status = "ACTIVE"; + + @Column(updatable = false) + private LocalDateTime grantedAt = LocalDateTime.now(); + + // Optional expiry date + private LocalDateTime expiresAt; + + // Set when the consent is revoked + private LocalDateTime revokedAt; + + // Optional note from the patient + @Column(columnDefinition = "TEXT") + private String reason; +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/repository/ConsentRepository.java b/backend/Backend/src/main/java/com/securehealth/backend/repository/ConsentRepository.java new file mode 100644 index 00000000..1d94788f --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/repository/ConsentRepository.java @@ -0,0 +1,24 @@ +package com.securehealth.backend.repository; + +import com.securehealth.backend.model.Consent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ConsentRepository extends JpaRepository { + + // All consents for a patient + List findByPatient_ProfileIdOrderByGrantedAtDesc(Long profileId); + + // Active consents for a patient + List findByPatient_ProfileIdAndStatus(Long profileId, String status); + + // What a specific provider is allowed to access + List findByGrantedTo_UserIdAndStatus(Long userId, String status); + + // Check if a specific consent already exists + boolean existsByPatient_ProfileIdAndGrantedTo_UserIdAndConsentTypeAndStatus( + Long profileId, Long userId, String consentType, String status); +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/ConsentService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/ConsentService.java new file mode 100644 index 00000000..2f9b4f6d --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/ConsentService.java @@ -0,0 +1,110 @@ +package com.securehealth.backend.service; + +import com.securehealth.backend.model.Consent; +import com.securehealth.backend.model.Login; +import com.securehealth.backend.model.PatientProfile; +import com.securehealth.backend.repository.ConsentRepository; +import com.securehealth.backend.repository.LoginRepository; +import com.securehealth.backend.repository.PatientProfileRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +public class ConsentService { + + @Autowired private ConsentRepository consentRepository; + @Autowired private LoginRepository loginRepository; + @Autowired private PatientProfileRepository patientProfileRepository; + + /** + * Get all consents for the currently logged-in patient. + */ + public List getMyConsents(String patientEmail) { + PatientProfile profile = getPatientProfile(patientEmail); + return consentRepository.findByPatient_ProfileIdOrderByGrantedAtDesc(profile.getProfileId()); + } + + /** + * Grant consent for a provider to access a specific data type. + */ + public Consent grantConsent(String patientEmail, Map payload) { + PatientProfile profile = getPatientProfile(patientEmail); + + Long grantedToId = Long.valueOf(payload.get("grantedToId").toString()); + String consentType = payload.getOrDefault("consentType", "ALL").toString().toUpperCase(); + String reason = payload.getOrDefault("reason", "").toString(); + + // Validate the provider exists + Login provider = loginRepository.findById(grantedToId) + .orElseThrow(() -> new RuntimeException("Provider not found with id: " + grantedToId)); + + // Check for duplicate active consent + if (consentRepository.existsByPatient_ProfileIdAndGrantedTo_UserIdAndConsentTypeAndStatus( + profile.getProfileId(), grantedToId, consentType, "ACTIVE")) { + throw new RuntimeException("Active consent already exists for this provider and data type."); + } + + Consent consent = new Consent(); + consent.setPatient(profile); + consent.setGrantedTo(provider); + consent.setConsentType(consentType); + consent.setStatus("ACTIVE"); + consent.setReason(reason); + + // Optional expiry + if (payload.containsKey("expiresAt") && payload.get("expiresAt") != null) { + consent.setExpiresAt(LocalDateTime.parse(payload.get("expiresAt").toString())); + } + + return consentRepository.save(consent); + } + + /** + * Revoke a patient's consent by ID. + */ + public Consent revokeConsent(String patientEmail, Long consentId) { + PatientProfile profile = getPatientProfile(patientEmail); + + Consent consent = consentRepository.findById(consentId) + .orElseThrow(() -> new RuntimeException("Consent not found with id: " + consentId)); + + // Ensure the patient owns this consent + if (!consent.getPatient().getProfileId().equals(profile.getProfileId())) { + throw new RuntimeException("You are not authorized to revoke this consent."); + } + + if ("REVOKED".equals(consent.getStatus())) { + throw new RuntimeException("This consent has already been revoked."); + } + + consent.setStatus("REVOKED"); + consent.setRevokedAt(LocalDateTime.now()); + + return consentRepository.save(consent); + } + + /** + * Utility: Check if a provider has active consent for a specific data type. + * Can be called from other services to enforce consent checks. + */ + public boolean hasConsent(Long patientProfileId, Long providerUserId, String consentType) { + // Check for specific type OR "ALL" type + return consentRepository.existsByPatient_ProfileIdAndGrantedTo_UserIdAndConsentTypeAndStatus( + patientProfileId, providerUserId, consentType, "ACTIVE") + || consentRepository.existsByPatient_ProfileIdAndGrantedTo_UserIdAndConsentTypeAndStatus( + patientProfileId, providerUserId, "ALL", "ACTIVE"); + } + + private PatientProfile getPatientProfile(String email) { + Login user = loginRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found with email: " + email)); + return patientProfileRepository.findByUser(user) + .orElseThrow(() -> new RuntimeException("Patient profile not found for user: " + email)); + } +} From c5223846b2aa654ba713d37af28866e41430d1e7 Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:37:56 +0530 Subject: [PATCH 5/7] feat: file upload for patients, daily back ups, user archival, and file encryption key to be set when deploying --- .../backend/SecureHealthApplication.java | 2 + .../controller/FileUploadController.java | 66 ++++++++ .../backend/model/ArchivedUser.java | 47 ++++++ .../com/securehealth/backend/model/Login.java | 6 + .../backend/model/MedicalRecord.java | 3 + .../repository/ArchivedUserRepository.java | 18 ++ .../backend/service/ArchivalService.java | 120 +++++++++++++ .../backend/service/BackupService.java | 152 +++++++++++++++++ .../backend/service/FileStorageService.java | 157 ++++++++++++++++++ .../src/main/resources/application.properties | 22 ++- 10 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/controller/FileUploadController.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/model/ArchivedUser.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/repository/ArchivedUserRepository.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/service/BackupService.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/service/FileStorageService.java diff --git a/backend/Backend/src/main/java/com/securehealth/backend/SecureHealthApplication.java b/backend/Backend/src/main/java/com/securehealth/backend/SecureHealthApplication.java index 672ff24d..74fe46f0 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/SecureHealthApplication.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/SecureHealthApplication.java @@ -2,6 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * The main entry point for the Secure Health Backend API. @@ -16,6 +17,7 @@ * @since 2024-01-01 */ @SpringBootApplication +@EnableScheduling public class SecureHealthApplication { /** diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/FileUploadController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/FileUploadController.java new file mode 100644 index 00000000..3870911e --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/FileUploadController.java @@ -0,0 +1,66 @@ +package com.securehealth.backend.controller; + +import com.securehealth.backend.service.FileStorageService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +@RestController +@RequestMapping("/api/files") +public class FileUploadController { + + @Autowired + private FileStorageService fileStorageService; + + /** + * Upload a file (image or document). The file is encrypted at rest using AES-256-GCM. + * Returns the unique filename for linking to medical records. + */ + @PostMapping("/upload") + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + try { + String filename = fileStorageService.storeFile(file); + return ResponseEntity.ok(Map.of( + "message", "File uploaded and encrypted successfully.", + "filename", filename + )); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + /** + * Retrieve and decrypt a stored file by its filename. + */ + @GetMapping("/{filename}") + public ResponseEntity getFile(@PathVariable String filename) { + try { + byte[] fileData = fileStorageService.loadFile(filename); + String extension = fileStorageService.getOriginalExtension(filename); + + MediaType mediaType = getMediaType(extension); + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename.replace(".enc", "") + "\"") + .body(fileData); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); + } + } + + private MediaType getMediaType(String extension) { + return switch (extension.toLowerCase()) { + case "jpg", "jpeg" -> MediaType.IMAGE_JPEG; + case "png" -> MediaType.IMAGE_PNG; + case "gif" -> MediaType.IMAGE_GIF; + case "pdf" -> MediaType.APPLICATION_PDF; + default -> MediaType.APPLICATION_OCTET_STREAM; + }; + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/ArchivedUser.java b/backend/Backend/src/main/java/com/securehealth/backend/model/ArchivedUser.java new file mode 100644 index 00000000..87876177 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/ArchivedUser.java @@ -0,0 +1,47 @@ +package com.securehealth.backend.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Stores a snapshot of a user account that has been archived due to inactivity. + * The original Login account is flagged as archived but not deleted, + * allowing for future restoration by an admin. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "archived_users") +public class ArchivedUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Reference to the original user's Login ID + @Column(nullable = false) + private Long originalUserId; + + @Column(nullable = false) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + // When the user was last active + private LocalDateTime lastActiveAt; + + // When the archival happened + @Column(updatable = false) + private LocalDateTime archivedAt = LocalDateTime.now(); + + // Reason for archival + @Column(columnDefinition = "TEXT") + private String reason; +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java b/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java index ab473277..33f6bb3c 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java @@ -97,6 +97,12 @@ public class Login { @Column(updatable = false) private LocalDateTime createdAt = LocalDateTime.now(); + // Track last login time for archival purposes + private LocalDateTime lastLoginAt; + + // Whether the user has been archived due to inactivity + private boolean archived = false; + // Compatibility methods for tests expecting snake_case naming public Long getUser_id() { return userId; diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/MedicalRecord.java b/backend/Backend/src/main/java/com/securehealth/backend/model/MedicalRecord.java index f61c2ace..495e1bf6 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/model/MedicalRecord.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/MedicalRecord.java @@ -32,6 +32,9 @@ public class MedicalRecord { @Column(columnDefinition = "TEXT") private String treatmentProvided; + // Path to uploaded file attachment (encrypted at rest) + private String attachmentUrl; + @Column(updatable = false) private LocalDateTime createdAt = LocalDateTime.now(); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/repository/ArchivedUserRepository.java b/backend/Backend/src/main/java/com/securehealth/backend/repository/ArchivedUserRepository.java new file mode 100644 index 00000000..16980a0d --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/repository/ArchivedUserRepository.java @@ -0,0 +1,18 @@ +package com.securehealth.backend.repository; + +import com.securehealth.backend.model.ArchivedUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ArchivedUserRepository extends JpaRepository { + + List findAllByOrderByArchivedAtDesc(); + + Optional findByOriginalUserId(Long originalUserId); + + Optional findByEmail(String email); +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java new file mode 100644 index 00000000..991c8131 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java @@ -0,0 +1,120 @@ +package com.securehealth.backend.service; + +import com.securehealth.backend.model.ArchivedUser; +import com.securehealth.backend.model.Login; +import com.securehealth.backend.repository.ArchivedUserRepository; +import com.securehealth.backend.repository.LoginRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Scheduled service that archives inactive user accounts. + * Users who haven't logged in for a configurable number of days are flagged + * as archived. Their data remains intact for potential restoration by an admin. + */ +@Service +@ConditionalOnProperty(name = "archival.enabled", havingValue = "true", matchIfMissing = true) +public class ArchivalService { + + private static final Logger log = LoggerFactory.getLogger(ArchivalService.class); + + @Autowired private LoginRepository loginRepository; + @Autowired private ArchivedUserRepository archivedUserRepository; + + @Value("${archival.inactivity-days:365}") + private int inactivityDays; + + /** + * Runs on the configured cron schedule (default: every Sunday at 3 AM). + * Identifies users with no login activity beyond the inactivity threshold + * and archives them. + */ + @Scheduled(cron = "${archival.cron:0 0 3 * * SUN}") + @Transactional + public void archiveInactiveUsers() { + log.info("Starting scheduled archival of inactive users (threshold: {} days)...", inactivityDays); + + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(inactivityDays); + + // Find users who either: + // 1. Have lastLoginAt older than cutoff, OR + // 2. Have never logged in (lastLoginAt is null) AND createdAt is older than cutoff + List allUsers = loginRepository.findAll(); + + int archivedCount = 0; + for (Login user : allUsers) { + // Skip already archived users + if (user.isArchived()) continue; + + boolean isInactive = false; + + if (user.getLastLoginAt() != null) { + isInactive = user.getLastLoginAt().isBefore(cutoffDate); + } else if (user.getCreatedAt() != null) { + // Never logged in — check account age + isInactive = user.getCreatedAt().isBefore(cutoffDate); + } + + if (isInactive) { + archiveUser(user); + archivedCount++; + } + } + + log.info("Archival complete. {} users archived.", archivedCount); + } + + private void archiveUser(Login user) { + // Create archive record + ArchivedUser archive = new ArchivedUser(); + archive.setOriginalUserId(user.getUserId()); + archive.setEmail(user.getEmail()); + archive.setRole(user.getRole()); + archive.setLastActiveAt(user.getLastLoginAt() != null ? user.getLastLoginAt() : user.getCreatedAt()); + archive.setReason("Automatic archival: inactive for " + inactivityDays + " days."); + + archivedUserRepository.save(archive); + + // Flag the user as archived + user.setArchived(true); + loginRepository.save(user); + + log.info("Archived user: {} (ID: {})", user.getEmail(), user.getUserId()); + } + + /** + * Admin action: restore an archived user. + */ + @Transactional + public Login restoreUser(Long archivedUserId) { + ArchivedUser archive = archivedUserRepository.findById(archivedUserId) + .orElseThrow(() -> new RuntimeException("Archived user not found with id: " + archivedUserId)); + + Login user = loginRepository.findById(archive.getOriginalUserId()) + .orElseThrow(() -> new RuntimeException("Original user account not found for id: " + archive.getOriginalUserId())); + + user.setArchived(false); + loginRepository.save(user); + + archivedUserRepository.delete(archive); + + log.info("Restored user: {} (ID: {})", user.getEmail(), user.getUserId()); + return user; + } + + /** + * Admin action: get all archived users. + */ + public List getArchivedUsers() { + return archivedUserRepository.findAllByOrderByArchivedAtDesc(); + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/BackupService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/BackupService.java new file mode 100644 index 00000000..2afa842a --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/BackupService.java @@ -0,0 +1,152 @@ +package com.securehealth.backend.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.stream.Stream; + +/** + * Scheduled service that runs pg_dump to create daily encrypted database backups. + * Backup directory is configurable — point it to a local path, mounted EBS volume, + * or NFS share for remote storage. + */ +@Service +@ConditionalOnProperty(name = "backup.enabled", havingValue = "true", matchIfMissing = true) +public class BackupService { + + private static final Logger log = LoggerFactory.getLogger(BackupService.class); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); + + @Value("${backup.directory:./backups}") + private String backupDir; + + @Value("${backup.retention-days:7}") + private int retentionDays; + + @Value("${spring.datasource.url}") + private String datasourceUrl; + + @Value("${spring.datasource.username}") + private String dbUsername; + + @Value("${spring.datasource.password}") + private String dbPassword; + + /** + * Runs on the configured cron schedule (default: 2:00 AM daily). + */ + @Scheduled(cron = "${backup.cron:0 0 2 * * *}") + public void performBackup() { + log.info("Starting scheduled database backup..."); + + try { + // Ensure backup directory exists + Path dirPath = Paths.get(backupDir); + if (!Files.exists(dirPath)) { + Files.createDirectories(dirPath); + } + + String timestamp = LocalDateTime.now().format(FMT); + String filename = "backup_" + timestamp + ".sql"; + Path backupFile = dirPath.resolve(filename); + + // Extract host, port, and database from JDBC URL + // Format: jdbc:postgresql://host:port/dbname?params + String dbHost = extractHost(datasourceUrl); + String dbPort = extractPort(datasourceUrl); + String dbName = extractDbName(datasourceUrl); + + // Build pg_dump command + ProcessBuilder pb = new ProcessBuilder( + "pg_dump", + "-h", dbHost, + "-p", dbPort, + "-U", dbUsername, + "-F", "c", // custom format (compressed) + "-f", backupFile.toAbsolutePath().toString(), + dbName + ); + + // Pass password via environment variable (never on CLI) + pb.environment().put("PGPASSWORD", dbPassword); + pb.redirectErrorStream(true); + + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode == 0) { + long sizeKb = Files.size(backupFile) / 1024; + log.info("Backup completed successfully: {} ({} KB)", filename, sizeKb); + } else { + String errorOutput = new String(process.getInputStream().readAllBytes()); + log.error("Backup failed with exit code {}: {}", exitCode, errorOutput); + } + + // Clean up old backups + cleanOldBackups(dirPath); + + } catch (Exception e) { + log.error("Backup failed with exception: {}", e.getMessage(), e); + } + } + + private void cleanOldBackups(Path dirPath) { + log.info("Cleaning backups older than {} days...", retentionDays); + + try (Stream files = Files.list(dirPath)) { + LocalDate cutoff = LocalDate.now().minus(retentionDays, ChronoUnit.DAYS); + + files.filter(f -> f.getFileName().toString().startsWith("backup_")) + .filter(f -> { + try { + return Files.getLastModifiedTime(f).toInstant() + .isBefore(cutoff.atStartOfDay().toInstant(java.time.ZoneOffset.UTC)); + } catch (IOException e) { + return false; + } + }) + .forEach(f -> { + try { + Files.delete(f); + log.info("Deleted old backup: {}", f.getFileName()); + } catch (IOException e) { + log.warn("Failed to delete old backup: {}", f.getFileName()); + } + }); + } catch (IOException e) { + log.warn("Failed to clean old backups: {}", e.getMessage()); + } + } + + // --- JDBC URL Parsing Helpers --- + + private String extractHost(String url) { + // jdbc:postgresql://host:port/dbname + String afterProtocol = url.split("//")[1]; + return afterProtocol.split(":")[0]; + } + + private String extractPort(String url) { + String afterProtocol = url.split("//")[1]; + String hostPort = afterProtocol.split("/")[0]; + return hostPort.contains(":") ? hostPort.split(":")[1] : "5432"; + } + + private String extractDbName(String url) { + String afterProtocol = url.split("//")[1]; + String afterHost = afterProtocol.split("/")[1]; + return afterHost.split("\\?")[0]; + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/FileStorageService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/FileStorageService.java new file mode 100644 index 00000000..352f1ec0 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/FileStorageService.java @@ -0,0 +1,157 @@ +package com.securehealth.backend.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +/** + * Service for storing and retrieving encrypted files. + * Uses AES-256-GCM for encryption at rest. + * The storage directory is configurable — point it to a local path or a mounted network/S3 volume. + */ +@Service +public class FileStorageService { + + private static final String AES_ALGO = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + private static final List ALLOWED_EXTENSIONS = List.of( + "jpg", "jpeg", "png", "gif", "pdf", "doc", "docx", "txt", "csv" + ); + + @Value("${app.upload.dir:./uploads}") + private String uploadDir; + + @Value("${app.encryption.key}") + private String encryptionKeyBase64; + + /** + * Stores an uploaded file with AES-256-GCM encryption. + * Returns the unique filename used for retrieval. + */ + public String storeFile(MultipartFile file) throws IOException { + // 1. Validate + if (file.isEmpty()) { + throw new RuntimeException("Cannot upload empty file."); + } + + String originalName = file.getOriginalFilename(); + String extension = getExtension(originalName); + + if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + throw new RuntimeException("File type not allowed: " + extension + + ". Allowed: " + String.join(", ", ALLOWED_EXTENSIONS)); + } + + // 2. Generate unique filename + String uniqueFilename = UUID.randomUUID() + "." + extension + ".enc"; + + // 3. Ensure upload directory exists + Path dirPath = Paths.get(uploadDir); + if (!Files.exists(dirPath)) { + Files.createDirectories(dirPath); + } + + // 4. Encrypt and save + try { + byte[] plainBytes = file.getBytes(); + byte[] encryptedBytes = encrypt(plainBytes); + Files.write(dirPath.resolve(uniqueFilename), encryptedBytes); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt and store file: " + e.getMessage(), e); + } + + return uniqueFilename; + } + + /** + * Retrieves and decrypts a stored file. + * Returns the decrypted bytes. + */ + public byte[] loadFile(String filename) throws IOException { + Path filePath = Paths.get(uploadDir).resolve(filename); + + if (!Files.exists(filePath)) { + throw new RuntimeException("File not found: " + filename); + } + + try { + byte[] encryptedBytes = Files.readAllBytes(filePath); + return decrypt(encryptedBytes); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt file: " + e.getMessage(), e); + } + } + + /** + * Returns the original file extension from an encrypted filename. + * e.g., "uuid.pdf.enc" → "pdf" + */ + public String getOriginalExtension(String encryptedFilename) { + // Strip .enc suffix, then get the real extension + String withoutEnc = encryptedFilename.replace(".enc", ""); + return getExtension(withoutEnc); + } + + // --- Encryption --- + + private byte[] encrypt(byte[] data) throws Exception { + SecretKey key = getKey(); + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(AES_ALGO); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] cipherText = cipher.doFinal(data); + + // Prepend IV to ciphertext for storage: [IV | ciphertext] + byte[] result = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, result, 0, iv.length); + System.arraycopy(cipherText, 0, result, iv.length, cipherText.length); + + return result; + } + + private byte[] decrypt(byte[] data) throws Exception { + SecretKey key = getKey(); + + // Extract IV from first 12 bytes + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(data, 0, iv, 0, GCM_IV_LENGTH); + + // Extract ciphertext + byte[] cipherText = new byte[data.length - GCM_IV_LENGTH]; + System.arraycopy(data, GCM_IV_LENGTH, cipherText, 0, cipherText.length); + + Cipher cipher = Cipher.getInstance(AES_ALGO); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + + return cipher.doFinal(cipherText); + } + + private SecretKey getKey() { + byte[] keyBytes = Base64.getDecoder().decode(encryptionKeyBase64); + return new SecretKeySpec(keyBytes, "AES"); + } + + private String getExtension(String filename) { + if (filename == null || !filename.contains(".")) { + return ""; + } + return filename.substring(filename.lastIndexOf('.') + 1); + } +} diff --git a/backend/Backend/src/main/resources/application.properties b/backend/Backend/src/main/resources/application.properties index 7e0c8806..17441ed8 100644 --- a/backend/Backend/src/main/resources/application.properties +++ b/backend/Backend/src/main/resources/application.properties @@ -37,4 +37,24 @@ spring.data.redis.port=6379 # If using Docker networking, use the container name: # spring.data.redis.host=redis -logging.level.org.springframework.security=${SECURITY_LOG_LEVEL:TRACE} \ No newline at end of file +logging.level.org.springframework.security=${SECURITY_LOG_LEVEL:TRACE} + +# File Upload Configuration +app.upload.dir=${UPLOAD_DIR:./uploads} +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + +# AES-256 Encryption Key (Base64-encoded 32-byte key) +# IMPORTANT: Generate your own key for production! Use: openssl rand -base64 32 +app.encryption.key=${APP_ENCRYPTION_KEY:dGhpcyBpcyBhIDMyIGJ5dGUga2V5IGZvciB0ZXN0aW5nIQ==} + +# Database Backup Configuration +backup.enabled=${BACKUP_ENABLED:true} +backup.cron=${BACKUP_CRON:0 0 2 * * *} +backup.directory=${BACKUP_DIR:./backups} +backup.retention-days=${BACKUP_RETENTION:7} + +# Data Archival Configuration +archival.enabled=${ARCHIVAL_ENABLED:true} +archival.cron=${ARCHIVAL_CRON:0 0 3 * * SUN} +archival.inactivity-days=${ARCHIVAL_INACTIVITY_DAYS:365} \ No newline at end of file From 39469c0056ffcc29570c54e32560782a155b4c55 Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:33:26 +0530 Subject: [PATCH 6/7] fix/ merge conflict issues, and JPA hibernate issues --- .../backend/config/SecurityConfig.java | 6 ++++ .../exception/GlobalExceptionHandler.java | 22 ------------- .../com/securehealth/backend/model/Login.java | 3 +- .../backend/service/ArchivalService.java | 2 +- .../src/main/resources/application.properties | 31 +++++++++++++++++-- 5 files changed, 38 insertions(+), 26 deletions(-) diff --git a/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java b/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java index 53ce84d1..8045df8f 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/config/SecurityConfig.java @@ -34,6 +34,12 @@ public class SecurityConfig { @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; + @Autowired + private AuditLogRepository auditLogRepository; + + @Value("${app.cors.allowed-origins:http://localhost:3000}") + private String allowedOrigins; + @Bean public PasswordEncoder passwordEncoder() { return new Argon2PasswordEncoder(16, 32, 1, 4096, 3); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java b/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java index 8a2da84e..0f6e8f38 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/exception/GlobalExceptionHandler.java @@ -120,27 +120,5 @@ public ResponseEntity handleAll(Exception ex) { "Internal Server Error", "An unexpected error occurred. Please try again later."); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); - public ResponseEntity> handleRuntimeException(RuntimeException ex) { - - logger.error("Runtime exception occurred", ex); - - Map response = new HashMap<>(); - response.put("error", ex.getMessage()); - - HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; - - if (ex.getMessage() != null) { - if (ex.getMessage().contains("404")) { - status = HttpStatus.NOT_FOUND; - } else if (ex.getMessage().contains("400")) { - status = HttpStatus.BAD_REQUEST; - } else if (ex.getMessage().contains("403")) { - status = HttpStatus.FORBIDDEN; - } else if (ex.getMessage().contains("401")) { - status = HttpStatus.UNAUTHORIZED; - } - } - - return new ResponseEntity<>(response, status); } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java b/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java index 33f6bb3c..cfc8e0b3 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/Login.java @@ -101,7 +101,8 @@ public class Login { private LocalDateTime lastLoginAt; // Whether the user has been archived due to inactivity - private boolean archived = false; + @Column(columnDefinition = "boolean default false") + private Boolean archived = false; // Compatibility methods for tests expecting snake_case naming public Long getUser_id() { diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java index 991c8131..7d291fd9 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/ArchivalService.java @@ -53,7 +53,7 @@ public void archiveInactiveUsers() { int archivedCount = 0; for (Login user : allUsers) { // Skip already archived users - if (user.isArchived()) continue; + if (Boolean.TRUE.equals(user.getArchived())) continue; boolean isInactive = false; diff --git a/backend/Backend/src/main/resources/application.properties b/backend/Backend/src/main/resources/application.properties index 9c2c179f..507a52bc 100644 --- a/backend/Backend/src/main/resources/application.properties +++ b/backend/Backend/src/main/resources/application.properties @@ -57,7 +57,7 @@ spring.data.redis.port=6379 # If using Docker networking, use the container name: # spring.data.redis.host=redis -logging.level.org.springframework.security=TRACE +logging.level.org.springframework.security=INFO # ------------------------- # ACTUATOR + PROMETHEUS @@ -72,4 +72,31 @@ management.endpoint.prometheus.cache.time-to-live=0 management.metrics.export.prometheus.openmetrics.enabled=false server.tomcat.relaxed-path-chars=_ -server.tomcat.relaxed-query-chars=_ \ No newline at end of file +server.tomcat.relaxed-query-chars=_ + +# ------------------------- +# NEW FEATURES CONFIGURATION +# ------------------------- + +# File Upload Configuration +app.upload.dir=${UPLOAD_DIR:./uploads} +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=10MB + +# AES-256 Encryption Key +app.encryption.key=${APP_ENCRYPTION_KEY:dGhpcyBpcyBhIDMyIGJ5dGUga2V5IGZvciB0ZXN0aW5nIQ==} + +# Database Backup Configuration +backup.enabled=${BACKUP_ENABLED:true} +backup.cron=${BACKUP_CRON:0 0 2 * * *} +backup.directory=${BACKUP_DIR:./backups} +backup.retention-days=${BACKUP_RETENTION:7} + +# Data Archival Configuration +archival.enabled=${ARCHIVAL_ENABLED:true} +archival.cron=${ARCHIVAL_CRON:0 0 3 * * SUN} +archival.inactivity-days=${ARCHIVAL_INACTIVITY_DAYS:365} + +# Default Admin Account +app.admin.email=${APP_ADMIN_EMAIL:admin@securehealth.com} +app.admin.password=${APP_ADMIN_PASSWORD:Admin@12345678} \ No newline at end of file From f35433162f455706c0063caca852d8ae616b10ff Mon Sep 17 00:00:00 2001 From: totallynotmanas <108781322+totallynotmanas@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:13:25 +0530 Subject: [PATCH 7/7] bug fix/ fixed bugs given by front end engineer --- .../backend/config/WebConfig.java | 23 +++++++ .../controller/AppointmentController.java | 55 ++++++++++++--- .../backend/controller/CatalogController.java | 69 +++++++++++++++++++ .../backend/controller/DoctorController.java | 5 ++ .../controller/LabResultController.java | 20 +++++- .../controller/MedicalRecordController.java | 14 +++- .../backend/controller/PatientController.java | 16 +++-- .../controller/PrescriptionController.java | 30 +++++++- .../controller/VitalSignController.java | 28 +++++++- .../backend/dto/AppointmentDTO.java | 3 +- .../backend/dto/AppointmentRequest.java | 9 +++ .../backend/dto/LabTestRequest.java | 8 +++ .../backend/dto/MedicalRecordDTO.java | 5 +- .../backend/dto/MedicalRecordRequest.java | 9 +++ .../securehealth/backend/dto/PatientDTO.java | 18 +++++ .../backend/dto/PrescriptionDTO.java | 3 + .../backend/dto/PrescriptionRequest.java | 12 ++++ .../backend/dto/VitalSignRequest.java | 10 +++ .../backend/model/Appointment.java | 5 +- .../backend/model/AppointmentStatus.java | 10 +++ .../backend/model/Prescription.java | 6 ++ .../repository/AppointmentRepository.java | 11 +-- .../repository/DoctorProfileRepository.java | 3 + .../backend/service/AdminService.java | 5 +- .../backend/service/AppointmentService.java | 42 ++++++++--- .../backend/service/DoctorService.java | 7 ++ .../backend/service/LabTestService.java | 26 +++++++ .../backend/service/MedicalRecordService.java | 30 ++++++-- .../backend/service/PatientService.java | 9 +-- .../backend/service/PrescriptionService.java | 68 +++++++++++++++++- .../backend/service/VitalSignService.java | 37 ++++++++++ .../controller/AppointmentControllerTest.java | 13 ++-- .../backend/service/AdminServiceTest.java | 3 +- .../service/AppointmentServiceTest.java | 9 +-- .../src/test/resources/application.properties | 9 +++ 35 files changed, 572 insertions(+), 58 deletions(-) create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/config/WebConfig.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/controller/CatalogController.java create mode 100644 backend/Backend/src/main/java/com/securehealth/backend/model/AppointmentStatus.java diff --git a/backend/Backend/src/main/java/com/securehealth/backend/config/WebConfig.java b/backend/Backend/src/main/java/com/securehealth/backend/config/WebConfig.java new file mode 100644 index 00000000..9e38cb25 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.securehealth.backend.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${app.cors.allowed-origins:http://localhost:3000}") + private String allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins.split(",")) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java index b94a6645..79c3b38b 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/AppointmentController.java @@ -3,6 +3,7 @@ import com.securehealth.backend.dto.AppointmentDTO; import com.securehealth.backend.dto.AppointmentRequest; import com.securehealth.backend.model.Appointment; +import com.securehealth.backend.model.AppointmentStatus; import com.securehealth.backend.repository.AppointmentRepository; import com.securehealth.backend.security.PatientAccessValidator; import com.securehealth.backend.service.AppointmentService; @@ -12,6 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.time.LocalDate; @@ -48,7 +50,8 @@ public ResponseEntity> getAvailableSlots( } @PostMapping - public ResponseEntity createAppointment(@RequestBody AppointmentRequest request, Authentication auth) { + @PreAuthorize("hasAuthority('PATIENT')") + public ResponseEntity createAppointment(@Valid @RequestBody AppointmentRequest request, Authentication auth) { try { // Extract the email from the JWT token String email = auth.getName(); @@ -68,7 +71,14 @@ public ResponseEntity createAppointment(@RequestBody AppointmentRequest reque @PutMapping("/{id}/approve") @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity approveAppointment(@PathVariable Long id) { + public ResponseEntity approveAppointment(@PathVariable Long id, Authentication auth) { + // Mock-safe security check for unit tests + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ADMIN")); + if (!isAdmin) { + return ResponseEntity.status(403).body("Forbidden: Only administrative staff can approve appointments."); + } + try { Appointment approved = appointmentService.approveAppointment(id); return ResponseEntity.ok(approved); @@ -79,7 +89,14 @@ public ResponseEntity approveAppointment(@PathVariable Long id) { @PutMapping("/{id}/reject") @PreAuthorize("hasAuthority('ADMIN')") - public ResponseEntity rejectAppointment(@PathVariable Long id, @RequestBody(required = false) String reason) { + public ResponseEntity rejectAppointment(@PathVariable Long id, @RequestBody(required = false) String reason, Authentication auth) { + // Mock-safe security check for unit tests + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ADMIN")); + if (!isAdmin) { + return ResponseEntity.status(403).body("Forbidden: Only administrative staff can approve appointments."); + } + try { Appointment rejected = appointmentService.rejectAppointment(id, reason); return ResponseEntity.ok(rejected); @@ -103,17 +120,17 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { @GetMapping("/status/{status}") public ResponseEntity> getByStatus(@PathVariable String status) { - return ResponseEntity.ok(appointmentRepository.findByStatus(status)); + return ResponseEntity.ok(appointmentRepository.findByStatus(AppointmentStatus.valueOf(status.toUpperCase()))); } @GetMapping("/stats") public ResponseEntity getStats() { java.util.Map stats = new java.util.HashMap<>(); stats.put("total", appointmentRepository.count()); - stats.put("pending", appointmentRepository.countByStatus("PENDING")); - stats.put("scheduled", appointmentRepository.countByStatus("SCHEDULED")); - stats.put("completed", appointmentRepository.countByStatus("COMPLETED")); - stats.put("cancelled", appointmentRepository.countByStatus("CANCELLED")); + stats.put("pending", appointmentRepository.countByStatus(AppointmentStatus.PENDING_APPROVAL)); + stats.put("scheduled", appointmentRepository.countByStatus(AppointmentStatus.SCHEDULED)); + stats.put("completed", appointmentRepository.countByStatus(AppointmentStatus.COMPLETED)); + stats.put("cancelled", appointmentRepository.countByStatus(AppointmentStatus.CANCELLED)); return ResponseEntity.ok(stats); } @@ -139,4 +156,26 @@ public ResponseEntity updateAppointment( return ResponseEntity.status(400).body(e.getMessage()); } } + + @PutMapping("/{id}/cancel") + @PreAuthorize("hasAnyAuthority('ADMIN', 'DOCTOR', 'PATIENT')") + public ResponseEntity cancelAppointment(@PathVariable Long id, Authentication auth) { + try { + String role = auth.getAuthorities().stream().findFirst().get().getAuthority(); + return ResponseEntity.ok(appointmentService.cancelAppointment(id, auth.getName(), role)); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity deleteAppointment(@PathVariable Long id) { + try { + appointmentService.deleteAppointment(id); + return ResponseEntity.ok("Appointment deleted successfully."); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/CatalogController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/CatalogController.java new file mode 100644 index 00000000..0c689705 --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/CatalogController.java @@ -0,0 +1,69 @@ +package com.securehealth.backend.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * Controller to provide static/mock data for frontend dropdowns. + */ +@RestController +@RequestMapping("/api") +public class CatalogController { + + @GetMapping("/medications") + public ResponseEntity getMedications() { + return ResponseEntity.ok(List.of( + Map.of("id", 1, "name", "Amoxicillin"), + Map.of("id", 2, "name", "Lisinopril"), + Map.of("id", 3, "name", "Atorvastatin"), + Map.of("id", 4, "name", "Metformin"), + Map.of("id", 5, "name", "Ibuprofen") + )); + } + + @GetMapping("/test-types") + public ResponseEntity getTestTypes() { + return ResponseEntity.ok(List.of( + Map.of("id", 1, "name", "Complete Blood Count (CBC)"), + Map.of("id", 2, "name", "Basic Metabolic Panel (BMP)"), + Map.of("id", 3, "name", "Lipid Panel"), + Map.of("id", 4, "name", "Urinalysis"), + Map.of("id", 5, "name", "HbA1C") + )); + } + + @GetMapping("/prescription-protocols") + public ResponseEntity getPrescriptionProtocols() { + return ResponseEntity.ok(List.of( + Map.of("id", 1, "name", "Standard Antibiotic Protocol"), + Map.of("id", 2, "name", "Hypertension Maintenance"), + Map.of("id", 3, "name", "Diabetes Type 2 Starter") + )); + } + + @GetMapping("/conditions") + public ResponseEntity getConditions() { + return ResponseEntity.ok(List.of( + Map.of("id", 1, "name", "Hypertension"), + Map.of("id", 2, "name", "Type 2 Diabetes"), + Map.of("id", 3, "name", "Hyperlipidemia"), + Map.of("id", 4, "name", "Asthma") + )); + } + + @GetMapping("/hospital-departments") + public ResponseEntity getHospitalDepartments() { + return ResponseEntity.ok(List.of( + Map.of("id", 1, "name", "Cardiology"), + Map.of("id", 2, "name", "Neurology"), + Map.of("id", 3, "name", "Pediatrics"), + Map.of("id", 4, "name", "Orthopedics"), + Map.of("id", 5, "name", "General Practice") + )); + } +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/DoctorController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/DoctorController.java index 48e8e80f..97f588bb 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/DoctorController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/DoctorController.java @@ -49,6 +49,11 @@ public ResponseEntity> getDoctorsBySpecialty(@PathVariable Strin return ResponseEntity.ok(doctorService.getDoctorsBySpecialty(specialty)); } + @GetMapping("/department/{department}") + public ResponseEntity> getDoctorsByDepartment(@PathVariable String department) { + return ResponseEntity.ok(doctorService.getDoctorsByDepartment(department)); + } + @PutMapping("/{id}") public ResponseEntity updateDoctorProfile( @PathVariable Long id, diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java index 5eb33e10..6b5487ad 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/LabResultController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -38,7 +39,7 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { @PostMapping @PreAuthorize("hasAnyAuthority('DOCTOR', 'ADMIN', 'LAB_TECHNICIAN')") - public ResponseEntity createLabTest(@RequestBody com.securehealth.backend.dto.LabTestRequest request, Authentication auth) { + public ResponseEntity createLabTest(@Valid @RequestBody com.securehealth.backend.dto.LabTestRequest request, Authentication auth) { try { LabTest newLabTest = labTestService.createLabTest(request, auth.getName()); return ResponseEntity.ok(newLabTest); @@ -46,4 +47,21 @@ public ResponseEntity createLabTest(@RequestBody com.securehealth.backend.dto return ResponseEntity.badRequest().body(e.getMessage()); } } + + @GetMapping("/pending") + @PreAuthorize("hasAnyAuthority('LAB_TECHNICIAN', 'ADMIN', 'DOCTOR')") + public ResponseEntity> getPendingLabTests() { + return ResponseEntity.ok(labTestService.getPendingLabTests()); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity deleteLabTest(@PathVariable Long id) { + try { + labTestService.deleteLabTest(id); + return ResponseEntity.ok("Lab result deleted successfully."); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java index 594f16b6..aa67c62f 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/MedicalRecordController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -36,7 +37,7 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { @PostMapping @PreAuthorize("hasAuthority('DOCTOR')") - public ResponseEntity createMedicalRecord(@RequestBody com.securehealth.backend.dto.MedicalRecordRequest request, Authentication auth) { + public ResponseEntity createMedicalRecord(@Valid @RequestBody com.securehealth.backend.dto.MedicalRecordRequest request, Authentication auth) { try { MedicalRecord newRecord = medicalRecordService.createMedicalRecord(request, auth.getName()); return ResponseEntity.ok(newRecord); @@ -44,4 +45,15 @@ public ResponseEntity createMedicalRecord(@RequestBody com.securehealth.backe return ResponseEntity.badRequest().body(e.getMessage()); } } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity deleteMedicalRecord(@PathVariable Long id, Authentication auth) { + try { + medicalRecordService.deleteMedicalRecord(id, auth.getName()); + return ResponseEntity.ok("Medical record deleted successfully."); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/PatientController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/PatientController.java index e68bd5c8..95f2757e 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/PatientController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/PatientController.java @@ -2,10 +2,14 @@ import com.securehealth.backend.dto.PatientDTO; import com.securehealth.backend.service.PatientService; +import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -31,8 +35,12 @@ private String getCurrentRole(Authentication auth) { } @GetMapping - public ResponseEntity> getAllPatients(Authentication auth) { - return ResponseEntity.ok(patientService.getAllPatients(getCurrentRole(auth))); + public ResponseEntity> getAllPatients( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + Authentication auth) { + Pageable pageable = PageRequest.of(page, size); + return ResponseEntity.ok(patientService.getAllPatients(getCurrentRole(auth), pageable)); } @GetMapping("/me") @@ -46,12 +54,12 @@ public ResponseEntity getPatientById(@PathVariable Long id, Authenti } @PostMapping - public ResponseEntity createPatient(@RequestBody PatientDTO patientDTO, Authentication auth) { + public ResponseEntity createPatient(@Valid @RequestBody PatientDTO patientDTO, Authentication auth) { return ResponseEntity.ok(patientService.createPatientProfile(patientDTO, getCurrentEmail(auth))); } @PutMapping("/{id}") - public ResponseEntity updatePatient(@PathVariable Long id, @RequestBody PatientDTO patientDTO, Authentication auth) { + public ResponseEntity updatePatient(@PathVariable Long id, @Valid @RequestBody PatientDTO patientDTO, Authentication auth) { return ResponseEntity.ok(patientService.updatePatientProfile(id, patientDTO, getCurrentEmail(auth), getCurrentRole(auth))); } diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java index bcb99a39..1e4c55c2 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/PrescriptionController.java @@ -9,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -36,7 +37,7 @@ public ResponseEntity getById(@PathVariable Long id, Authentication auth) { @PostMapping @PreAuthorize("hasAuthority('DOCTOR')") - public ResponseEntity createPrescription(@RequestBody com.securehealth.backend.dto.PrescriptionRequest request, Authentication auth) { + public ResponseEntity createPrescription(@Valid @RequestBody com.securehealth.backend.dto.PrescriptionRequest request, Authentication auth) { try { Prescription newPrescription = prescriptionService.createPrescription(request, auth.getName()); return ResponseEntity.ok(newPrescription); @@ -44,4 +45,31 @@ public ResponseEntity createPrescription(@RequestBody com.securehealth.backen return ResponseEntity.badRequest().body(e.getMessage()); } } + + @GetMapping("/patient/{patientId}/active") + public ResponseEntity> getActiveByPatient(@PathVariable Long patientId, Authentication auth) { + accessValidator.validateAccess(patientId, auth); + return ResponseEntity.ok(prescriptionService.getActivePrescriptionsByPatient(patientId)); + } + + @PutMapping("/{id}/refill") + @PreAuthorize("hasAuthority('DOCTOR')") + public ResponseEntity refillPrescription(@PathVariable Long id, Authentication auth) { + try { + return ResponseEntity.ok(prescriptionService.refillPrescription(id, auth.getName())); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity deletePrescription(@PathVariable Long id, Authentication auth) { + try { + prescriptionService.deletePrescription(id, auth.getName()); + return ResponseEntity.ok("Prescription deleted successfully."); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/controller/VitalSignController.java b/backend/Backend/src/main/java/com/securehealth/backend/controller/VitalSignController.java index 2a094db9..af746ef3 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/controller/VitalSignController.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/controller/VitalSignController.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.access.prepost.PreAuthorize; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @@ -22,14 +23,24 @@ public class VitalSignController { @Autowired private VitalSignService vitalSignService; @GetMapping("/patient/{patientId}") - public ResponseEntity> getByPatient(@PathVariable Long patientId, Authentication auth) { + public ResponseEntity getByPatient(@PathVariable Long patientId, Authentication auth) { accessValidator.validateAccess(patientId, auth); - return ResponseEntity.ok(vitalSignRepository.findByPatient_ProfileIdOrderByRecordedAtDesc(patientId)); + return ResponseEntity.ok(vitalSignService.getVitalSignsByPatient(patientId)); + } + + @GetMapping("/patient/{patientId}/latest") + public ResponseEntity getLatestByPatient(@PathVariable Long patientId, Authentication auth) { + accessValidator.validateAccess(patientId, auth); + try { + return ResponseEntity.ok(vitalSignService.getLatestVitalSignByPatient(patientId)); + } catch (RuntimeException e) { + return ResponseEntity.status(404).body(e.getMessage()); + } } @PostMapping @PreAuthorize("hasAnyAuthority('DOCTOR', 'ADMIN', 'NURSE')") - public ResponseEntity createVitalSign(@RequestBody com.securehealth.backend.dto.VitalSignRequest request, Authentication auth) { + public ResponseEntity createVitalSign(@Valid @RequestBody com.securehealth.backend.dto.VitalSignRequest request, Authentication auth) { try { VitalSign newVital = vitalSignService.createVitalSign(request, auth.getName()); return ResponseEntity.ok(newVital); @@ -37,4 +48,15 @@ public ResponseEntity createVitalSign(@RequestBody com.securehealth.backend.d return ResponseEntity.badRequest().body(e.getMessage()); } } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAuthority('ADMIN')") + public ResponseEntity deleteVitalSign(@PathVariable Long id) { + try { + vitalSignService.deleteVitalSign(id); + return ResponseEntity.ok("Vital sign deleted successfully."); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(e.getMessage()); + } + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentDTO.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentDTO.java index e4babd3e..55f260da 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentDTO.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentDTO.java @@ -1,6 +1,7 @@ package com.securehealth.backend.dto; import lombok.Data; import java.time.LocalDateTime; +import com.securehealth.backend.model.AppointmentStatus; @Data public class AppointmentDTO { @@ -9,6 +10,6 @@ public class AppointmentDTO { private String doctorName; private String patientName; private LocalDateTime appointmentDate; - private String status; + private AppointmentStatus status; private String reasonForVisit; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentRequest.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentRequest.java index bc7458de..9bc1c62e 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentRequest.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/AppointmentRequest.java @@ -1,11 +1,20 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import java.time.LocalDateTime; @Data public class AppointmentRequest { + @NotNull(message = "Doctor ID is required") private Long doctorId; // The ID of the doctor they are booking + + @NotNull(message = "Appointment date is required") + @Future(message = "Appointment date must be in the future") private LocalDateTime appointmentDate; // The exact date and time they selected + + @NotBlank(message = "Reason for visit is required") private String reasonForVisit; // Optional notes from the patient } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/LabTestRequest.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/LabTestRequest.java index fe893beb..951b51ad 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/LabTestRequest.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/LabTestRequest.java @@ -1,12 +1,20 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; @Data public class LabTestRequest { + @NotNull(message = "Patient ID is required") private Long patientId; + + @NotBlank(message = "Test name is required") private String testName; // e.g., "Complete Blood Count" + + @NotBlank(message = "Test category is required") private String testCategory; // e.g., "Hematology" + private String resultValue; // e.g., "14.5" private String unit; // e.g., "g/dL" private String referenceRange; // e.g., "13.8 - 17.2" diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordDTO.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordDTO.java index 7af1d1fc..91b31aa5 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordDTO.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordDTO.java @@ -5,9 +5,12 @@ @Data public class MedicalRecordDTO { private Long recordId; + private Long patientId; private String doctorName; private String diagnosis; private String symptoms; private String treatmentProvided; - private LocalDateTime recordedAt; + private String notes; + private LocalDateTime recordDate; + private LocalDateTime createdAt; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordRequest.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordRequest.java index 38f798c1..b12c0fda 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordRequest.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/MedicalRecordRequest.java @@ -1,11 +1,20 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; @Data public class MedicalRecordRequest { + @NotNull(message = "Patient ID is required") private Long patientId; + + @NotBlank(message = "Diagnosis is required") private String diagnosis; + + @NotBlank(message = "Symptoms are required") private String symptoms; + + @NotBlank(message = "Treatment provided is required") private String treatmentProvided; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/PatientDTO.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/PatientDTO.java index a7d4e53d..f79a7d3b 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/PatientDTO.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/PatientDTO.java @@ -1,18 +1,36 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import java.time.LocalDate; @Data public class PatientDTO { private Long id; // Matches the Frontend's expectation of an 'id' + + @NotBlank(message = "First name is required") private String firstName; + + @NotBlank(message = "Last name is required") private String lastName; + + @Email(message = "Invalid email format") private String email; // From the linked Login account + + @NotNull(message = "Date of birth is required") private LocalDate dateOfBirth; + + @NotBlank(message = "Gender is required") private String gender; + + @NotBlank(message = "Contact number is required") private String contactNumber; + + @NotBlank(message = "Address is required") private String address; + private String medicalHistory; private Long assignedDoctorId; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionDTO.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionDTO.java index d4e658be..8205ce9d 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionDTO.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionDTO.java @@ -13,4 +13,7 @@ public class PrescriptionDTO { private String specialInstructions; private String status; private LocalDateTime issuedAt; + private LocalDateTime startDate; + private LocalDateTime endDate; + private Integer refillsRemaining; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionRequest.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionRequest.java index 3c83941d..b8f50446 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionRequest.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/PrescriptionRequest.java @@ -1,13 +1,25 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; @Data public class PrescriptionRequest { + @NotNull(message = "Patient ID is required") private Long patientId; + + @NotBlank(message = "Medication name is required") private String medicationName; + + @NotBlank(message = "Dosage is required") private String dosage; + + @NotBlank(message = "Frequency is required") private String frequency; + + @NotBlank(message = "Duration is required") private String duration; + private String specialInstructions; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/dto/VitalSignRequest.java b/backend/Backend/src/main/java/com/securehealth/backend/dto/VitalSignRequest.java index 26a1aa0a..7f88a98a 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/dto/VitalSignRequest.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/dto/VitalSignRequest.java @@ -1,13 +1,23 @@ package com.securehealth.backend.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; @Data public class VitalSignRequest { + @NotNull(message = "Patient ID is required") private Long patientId; + + @NotBlank(message = "Blood pressure is required") private String bloodPressure; // e.g., "120/80" + + @NotNull(message = "Heart rate is required") private Integer heartRate; // e.g., 75 + + @NotNull(message = "Temperature is required") private Double temperature; // e.g., 98.6 + private Integer respiratoryRate; // e.g., 16 private Integer oxygenSaturation; // e.g., 99 private Double weight; // in kg or lbs diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/Appointment.java b/backend/Backend/src/main/java/com/securehealth/backend/model/Appointment.java index b79e3783..a2bebcc0 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/model/Appointment.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/Appointment.java @@ -28,9 +28,10 @@ public class Appointment { @Column(nullable = false) private LocalDateTime appointmentDate; - // Status: SCHEDULED, COMPLETED, CANCELLED, NO_SHOW + // Status: PENDING_APPROVAL, SCHEDULED, COMPLETED, CANCELLED, NO_SHOW, REJECTED + @Enumerated(EnumType.STRING) @Column(nullable = false) - private String status = "SCHEDULED"; + private AppointmentStatus status = AppointmentStatus.PENDING_APPROVAL; @Column(columnDefinition = "TEXT") private String reasonForVisit; diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/AppointmentStatus.java b/backend/Backend/src/main/java/com/securehealth/backend/model/AppointmentStatus.java new file mode 100644 index 00000000..8f55f9db --- /dev/null +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/AppointmentStatus.java @@ -0,0 +1,10 @@ +package com.securehealth.backend.model; + +public enum AppointmentStatus { + PENDING_APPROVAL, + SCHEDULED, + COMPLETED, + CANCELLED, + NO_SHOW, + REJECTED +} diff --git a/backend/Backend/src/main/java/com/securehealth/backend/model/Prescription.java b/backend/Backend/src/main/java/com/securehealth/backend/model/Prescription.java index 255dfd0c..68f6bd8c 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/model/Prescription.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/model/Prescription.java @@ -41,6 +41,12 @@ public class Prescription { @Column(updatable = false) private LocalDateTime issuedAt = LocalDateTime.now(); + private LocalDateTime startDate; + + private LocalDateTime endDate; + + private Integer refillsRemaining = 0; + // Status: ACTIVE, COMPLETED, CANCELLED private String status = "ACTIVE"; } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/repository/AppointmentRepository.java b/backend/Backend/src/main/java/com/securehealth/backend/repository/AppointmentRepository.java index 0f081cae..46fcf810 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/repository/AppointmentRepository.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/repository/AppointmentRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.securehealth.backend.model.PatientProfile; +import com.securehealth.backend.model.AppointmentStatus; import java.util.List; import java.time.LocalDateTime; @@ -29,7 +30,7 @@ List findByDoctor_UserIdAndAppointmentDateBetween( boolean existsByDoctor_UserIdAndAppointmentDateAndStatusNotIn( Long doctorId, java.time.LocalDateTime appointmentDate, - java.util.List statuses); + java.util.List statuses); List findByDoctor_UserIdOrderByAppointmentDateAsc(Long doctorId); @@ -39,11 +40,13 @@ boolean existsByDoctor_UserIdAndAppointmentDateAndStatusNotIn( List findDistinctPatientsByDoctorId(@Param("doctorId") Long doctorId); List findByPatient_ProfileId(Long patientId); - + List findByDoctor_UserId(Long doctorId); + // Counts appointments by their exact status - long countByStatus(String status); + long countByStatus(AppointmentStatus status); - List findByStatus(String status); + // Admin needs to see all pending approvals + List findByStatus(AppointmentStatus status); // Counts scheduled appointments for a specific time range (today) @Query("SELECT COUNT(a) FROM Appointment a WHERE a.appointmentDate >= :startOfDay " + diff --git a/backend/Backend/src/main/java/com/securehealth/backend/repository/DoctorProfileRepository.java b/backend/Backend/src/main/java/com/securehealth/backend/repository/DoctorProfileRepository.java index 4bd2c189..4d1fad1a 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/repository/DoctorProfileRepository.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/repository/DoctorProfileRepository.java @@ -17,5 +17,8 @@ public interface DoctorProfileRepository extends JpaRepository findBySpecialtyIgnoreCase(String specialty); + // Frontend: GET /doctors/department/:department + List findByDepartmentIgnoreCase(String department); + Optional findByUser_UserId(Long userId); } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/AdminService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/AdminService.java index ee0aba09..c5bce227 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/AdminService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/AdminService.java @@ -1,6 +1,7 @@ package com.securehealth.backend.service; import com.securehealth.backend.dto.AdminMetricsDTO; +import com.securehealth.backend.model.AppointmentStatus; import com.securehealth.backend.repository.AppointmentRepository; import com.securehealth.backend.repository.LoginRepository; import com.securehealth.backend.repository.PatientProfileRepository; @@ -32,10 +33,10 @@ public AdminMetricsDTO getDashboardMetrics() { metrics.setTotalPatients(patientProfileRepository.count()); // 2. Total Doctors - metrics.setTotalDoctors(loginRepository.countByRole("DOCTOR")); + metrics.setTotalDoctors(loginRepository.countByRole(Role.DOCTOR.name())); // 3. Pending Approvals - metrics.setPendingApprovals(appointmentRepository.countByStatus("PENDING_APPROVAL")); + metrics.setPendingApprovals(appointmentRepository.countByStatus(AppointmentStatus.PENDING_APPROVAL)); // 4. Today's Appointments LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/AppointmentService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/AppointmentService.java index e2eed8ab..b45604c2 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/AppointmentService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/AppointmentService.java @@ -57,7 +57,7 @@ public List getAvailableSlots(Long doctorId, LocalDate targetDate) { // Extract just the times of the booked appointments List bookedTimes = bookedAppointments.stream() - .filter(apt -> !apt.getStatus().equals("CANCELLED")) // Ignore cancelled ones + .filter(apt -> apt.getStatus() != com.securehealth.backend.model.AppointmentStatus.CANCELLED) // Ignore cancelled ones .map(apt -> apt.getAppointmentDate().toLocalTime()) .toList(); @@ -93,7 +93,7 @@ public Appointment createAppointment(AppointmentRequest request, String requeste boolean isSlotTaken = appointmentRepository.existsByDoctor_UserIdAndAppointmentDateAndStatusNotIn( doctor.getUserId(), request.getAppointmentDate(), - List.of("CANCELLED", "REJECTED")); + List.of(com.securehealth.backend.model.AppointmentStatus.CANCELLED, com.securehealth.backend.model.AppointmentStatus.REJECTED)); if (isSlotTaken) { throw new RuntimeException("409 Conflict: This time slot is currently unavailable or pending review."); @@ -106,7 +106,7 @@ public Appointment createAppointment(AppointmentRequest request, String requeste appointment.setReasonForVisit(request.getReasonForVisit()); // UPDATE: Set to PENDING instead of SCHEDULED - appointment.setStatus("PENDING_APPROVAL"); + appointment.setStatus(com.securehealth.backend.model.AppointmentStatus.PENDING_APPROVAL); return appointmentRepository.save(appointment); } @@ -119,11 +119,11 @@ public Appointment approveAppointment(Long appointmentId) { Appointment appointment = appointmentRepository.findById(appointmentId) .orElseThrow(() -> new RuntimeException("404: Appointment not found")); - if (!appointment.getStatus().equals("PENDING_APPROVAL")) { + if (appointment.getStatus() != com.securehealth.backend.model.AppointmentStatus.PENDING_APPROVAL) { throw new RuntimeException("400: Only pending appointments can be approved."); } - appointment.setStatus("SCHEDULED"); + appointment.setStatus(com.securehealth.backend.model.AppointmentStatus.SCHEDULED); return appointmentRepository.save(appointment); } @@ -135,11 +135,11 @@ public Appointment rejectAppointment(Long appointmentId, String rejectionReason) Appointment appointment = appointmentRepository.findById(appointmentId) .orElseThrow(() -> new RuntimeException("404: Appointment not found")); - if (!appointment.getStatus().equals("PENDING_APPROVAL")) { + if (appointment.getStatus() != com.securehealth.backend.model.AppointmentStatus.PENDING_APPROVAL) { throw new RuntimeException("400: Only pending appointments can be rejected."); } - appointment.setStatus("REJECTED"); + appointment.setStatus(com.securehealth.backend.model.AppointmentStatus.REJECTED); // Optional: If you added a 'adminNotes' column, you could save the reason here // appointment.setDoctorNotes("Rejected by Admin: " + rejectionReason); @@ -148,7 +148,7 @@ public Appointment rejectAppointment(Long appointmentId, String rejectionReason) @Transactional(readOnly = true) public List getPendingAppointments() { - return appointmentRepository.findByStatus("PENDING_APPROVAL").stream().map(app -> { + return appointmentRepository.findByStatus(com.securehealth.backend.model.AppointmentStatus.PENDING_APPROVAL).stream().map(app -> { AppointmentDTO dto = new AppointmentDTO(); dto.setAppointmentId(app.getAppointmentId()); dto.setDoctorId(app.getDoctor().getUserId()); @@ -171,7 +171,7 @@ public Appointment completeAppointment(Long id, String doctorEmail) { throw new RuntimeException("403: You can only complete your own appointments."); } - appointment.setStatus("COMPLETED"); + appointment.setStatus(com.securehealth.backend.model.AppointmentStatus.COMPLETED); return appointmentRepository.save(appointment); } @@ -192,6 +192,30 @@ public Appointment updateAppointment(Long id, AppointmentDTO request, String doc return appointmentRepository.save(appointment); } + @Transactional + public Appointment cancelAppointment(Long id, String requesterEmail, String role) { + Appointment appointment = appointmentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("404: Appointment not found")); + + if (!role.equals("ADMIN") && + !appointment.getDoctor().getEmail().equals(requesterEmail) && + !appointment.getPatient().getUser().getEmail().equals(requesterEmail)) { + throw new RuntimeException("403: You can only cancel your own appointments."); + } + + appointment.setStatus(com.securehealth.backend.model.AppointmentStatus.CANCELLED); + return appointmentRepository.save(appointment); + } + + @Transactional + public void deleteAppointment(Long id) { + if (!appointmentRepository.existsById(id)) { + throw new RuntimeException("404: Appointment not found"); + } + appointmentRepository.deleteById(id); + } + + @Transactional(readOnly = true) public List getAppointmentsByPatient(Long patientId) { return appointmentRepository.findByPatient_ProfileId(patientId).stream().map(app -> { diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/DoctorService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/DoctorService.java index ddbd0559..47f6e41e 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/DoctorService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/DoctorService.java @@ -37,6 +37,13 @@ public List getDoctorsBySpecialty(String specialty) { .collect(Collectors.toList()); } + @Transactional(readOnly = true) + public List getDoctorsByDepartment(String department) { + return doctorProfileRepository.findByDepartmentIgnoreCase(department).stream() + .map(this::mapToDTO) + .collect(Collectors.toList()); + } + @Transactional public DoctorDTO updateDoctorProfile(Long id, DoctorDTO dto, String requesterEmail, String requesterRole) { DoctorProfile profile = doctorProfileRepository.findById(id) diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/LabTestService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/LabTestService.java index 26b681e2..e86a4f40 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/LabTestService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/LabTestService.java @@ -64,4 +64,30 @@ public List getLabTestsByPatient(Long patientId) { return dto; }).collect(Collectors.toList()); } + + @Transactional(readOnly = true) + public List getPendingLabTests() { + return labTestRepository.findByStatusOrderByOrderedAtAsc("PENDING").stream().map(lt -> { + LabTestDTO dto = new LabTestDTO(); + dto.setTestId(lt.getTestId()); + dto.setOrderedByName(lt.getOrderedBy() != null ? lt.getOrderedBy().getEmail() : "Unknown Staff"); + dto.setTestName(lt.getTestName()); + dto.setTestCategory(lt.getTestCategory()); + dto.setResultValue(lt.getResultValue()); + dto.setUnit(lt.getUnit()); + dto.setReferenceRange(lt.getReferenceRange()); + dto.setRemarks(lt.getRemarks()); + dto.setStatus(lt.getStatus()); + dto.setOrderedAt(lt.getOrderedAt()); + return dto; + }).collect(Collectors.toList()); + } + + @Transactional + public void deleteLabTest(Long id) { + if (!labTestRepository.existsById(id)) { + throw new RuntimeException("404: Lab test not found"); + } + labTestRepository.deleteById(id); + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/MedicalRecordService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/MedicalRecordService.java index 5d6b1035..01fa64dd 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/MedicalRecordService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/MedicalRecordService.java @@ -5,6 +5,8 @@ import com.securehealth.backend.model.Login; import com.securehealth.backend.model.MedicalRecord; import com.securehealth.backend.model.PatientProfile; +import com.securehealth.backend.model.AuditLog; +import com.securehealth.backend.repository.AuditLogRepository; import com.securehealth.backend.repository.LoginRepository; import com.securehealth.backend.repository.MedicalRecordRepository; import com.securehealth.backend.repository.PatientProfileRepository; @@ -22,6 +24,7 @@ public class MedicalRecordService { @Autowired private MedicalRecordRepository medicalRecordRepository; @Autowired private LoginRepository loginRepository; @Autowired private PatientProfileRepository patientProfileRepository; + @Autowired private AuditLogRepository auditLogRepository; @Transactional public MedicalRecord createMedicalRecord(MedicalRecordRequest request, String doctorEmail) { @@ -38,22 +41,39 @@ public MedicalRecord createMedicalRecord(MedicalRecordRequest request, String do record.setSymptoms(request.getSymptoms()); record.setTreatmentProvided(request.getTreatmentProvided()); - return medicalRecordRepository.save(record); + MedicalRecord saved = medicalRecordRepository.save(record); + + // Audit Log + auditLogRepository.save(new AuditLog(doctorEmail, "MEDICAL_RECORD_CREATED", "INTERNAL", "SYSTEM", + "Created medical record for patient ID: " + patient.getProfileId() + ", Diagnosis: " + record.getDiagnosis())); + + return saved; } @Transactional(readOnly = true) public List getMedicalRecordsByPatient(Long patientId) { return medicalRecordRepository.findByPatient_ProfileId(patientId).stream().map(mr -> { MedicalRecordDTO dto = new MedicalRecordDTO(); dto.setRecordId(mr.getRecordId()); - - // Safely trigger the lazy load + dto.setPatientId(mr.getPatient().getProfileId()); dto.setDoctorName(mr.getDoctor() != null ? mr.getDoctor().getEmail() : "Unknown Doctor"); - dto.setDiagnosis(mr.getDiagnosis()); dto.setSymptoms(mr.getSymptoms()); dto.setTreatmentProvided(mr.getTreatmentProvided()); - dto.setRecordedAt(mr.getUpdatedAt()); + dto.setNotes(mr.getSymptoms()); // Using symptoms as notes if no explicit notes exist + dto.setRecordDate(mr.getUpdatedAt()); + dto.setCreatedAt(mr.getCreatedAt()); return dto; }).collect(Collectors.toList()); } + + @Transactional + public void deleteMedicalRecord(Long id, String adminEmail) { + MedicalRecord record = medicalRecordRepository.findById(id) + .orElseThrow(() -> new RuntimeException("404: Medical Record not found")); + + medicalRecordRepository.delete(record); + + auditLogRepository.save(new AuditLog(adminEmail, "MEDICAL_RECORD_DELETED", "INTERNAL", "SYSTEM", + "Deleted medical record ID: " + id + " for patient ID: " + record.getPatient().getProfileId())); + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/PatientService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/PatientService.java index 2292c2fa..7a9ebb76 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/PatientService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/PatientService.java @@ -8,6 +8,8 @@ import com.securehealth.backend.repository.PatientProfileRepository; import com.securehealth.backend.repository.AppointmentRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,14 +33,13 @@ public class PatientService { * Only Doctors and Admins should be able to pull a full list of patients. */ @Transactional(readOnly = true) - public List getAllPatients(String requesterRole) { + public Page getAllPatients(String requesterRole, Pageable pageable) { if (!requesterRole.equals("DOCTOR") && !requesterRole.equals("ADMIN")) { throw new RuntimeException("403 Forbidden: Insufficient privileges"); } - return patientProfileRepository.findAll().stream() - .map(this::mapToDTO) - .collect(Collectors.toList()); + return patientProfileRepository.findAll(pageable) + .map(this::mapToDTO); } /** diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/PrescriptionService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/PrescriptionService.java index 7e9c04de..3e3e478f 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/PrescriptionService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/PrescriptionService.java @@ -6,10 +6,13 @@ import com.securehealth.backend.model.Login; import com.securehealth.backend.model.PatientProfile; import com.securehealth.backend.model.Prescription; +import com.securehealth.backend.model.AuditLog; +import com.securehealth.backend.repository.AuditLogRepository; import com.securehealth.backend.repository.LoginRepository; import com.securehealth.backend.repository.PatientProfileRepository; import com.securehealth.backend.repository.PrescriptionRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -23,6 +26,7 @@ public class PrescriptionService { @Autowired private PrescriptionRepository prescriptionRepository; @Autowired private LoginRepository loginRepository; @Autowired private PatientProfileRepository patientProfileRepository; + @Autowired private AuditLogRepository auditLogRepository; @Transactional public Prescription createPrescription(PrescriptionRequest request, String doctorEmail) { @@ -41,8 +45,18 @@ public Prescription createPrescription(PrescriptionRequest request, String docto prescription.setDuration(request.getDuration()); prescription.setSpecialInstructions(request.getSpecialInstructions()); prescription.setStatus("ACTIVE"); + + // Default start date to now, end date dependent on duration parsing or frontend + prescription.setStartDate(LocalDateTime.now()); + prescription.setRefillsRemaining(0); - return prescriptionRepository.save(prescription); + Prescription saved = prescriptionRepository.save(prescription); + + // Audit Log + auditLogRepository.save(new AuditLog(doctorEmail, "PRESCRIPTION_CREATED", "INTERNAL", "SYSTEM", + "Prescribed " + prescription.getMedicationName() + " to patient ID: " + patient.getProfileId())); + + return saved; } @Transactional(readOnly = true) public List getPrescriptionsByPatient(Long patientId) { @@ -60,7 +74,59 @@ public List getPrescriptionsByPatient(Long patientId) { dto.setSpecialInstructions(p.getSpecialInstructions()); dto.setStatus(p.getStatus()); dto.setIssuedAt(p.getIssuedAt()); + dto.setStartDate(p.getStartDate()); + dto.setEndDate(p.getEndDate()); + dto.setRefillsRemaining(p.getRefillsRemaining()); return dto; }).collect(Collectors.toList()); } + + @Transactional(readOnly = true) + public List getActivePrescriptionsByPatient(Long patientId) { + return prescriptionRepository.findByPatient_ProfileIdAndStatus(patientId, "ACTIVE") + .stream().map(p -> { + PrescriptionDTO dto = new PrescriptionDTO(); + dto.setPrescriptionId(p.getPrescriptionId()); + dto.setDoctorName(p.getDoctor() != null ? p.getDoctor().getEmail() : "Unknown Doctor"); + dto.setMedicationName(p.getMedicationName()); + dto.setDosage(p.getDosage()); + dto.setFrequency(p.getFrequency()); + dto.setDuration(p.getDuration()); + dto.setSpecialInstructions(p.getSpecialInstructions()); + dto.setStatus(p.getStatus()); + dto.setIssuedAt(p.getIssuedAt()); + dto.setStartDate(p.getStartDate()); + dto.setEndDate(p.getEndDate()); + dto.setRefillsRemaining(p.getRefillsRemaining()); + return dto; + }).collect(Collectors.toList()); + } + + @Transactional + public Prescription refillPrescription(Long id, String doctorEmail) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new RuntimeException("404: Prescription not found")); + + if (!prescription.getDoctor().getEmail().equals(doctorEmail)) { + throw new RuntimeException("403: You can only refill prescriptions you issued."); + } + + if (prescription.getRefillsRemaining() <= 0) { + throw new RuntimeException("400: No refills remaining. Please create a new prescription."); + } + + prescription.setRefillsRemaining(prescription.getRefillsRemaining() - 1); + return prescriptionRepository.save(prescription); + } + + @Transactional + public void deletePrescription(Long id, String adminEmail) { + Prescription prescription = prescriptionRepository.findById(id) + .orElseThrow(() -> new RuntimeException("404: Prescription not found")); + + prescriptionRepository.delete(prescription); + + auditLogRepository.save(new AuditLog(adminEmail, "PRESCRIPTION_DELETED", "INTERNAL", "SYSTEM", + "Deleted prescription ID: " + id + " for patient ID: " + prescription.getPatient().getProfileId())); + } } \ No newline at end of file diff --git a/backend/Backend/src/main/java/com/securehealth/backend/service/VitalSignService.java b/backend/Backend/src/main/java/com/securehealth/backend/service/VitalSignService.java index 21adbe55..339e3aee 100644 --- a/backend/Backend/src/main/java/com/securehealth/backend/service/VitalSignService.java +++ b/backend/Backend/src/main/java/com/securehealth/backend/service/VitalSignService.java @@ -40,4 +40,41 @@ public VitalSign createVitalSign(VitalSignRequest request, String recorderEmail) return vitalSignRepository.save(vitalSign); } + + @Transactional(readOnly = true) + public java.util.List getVitalSignsByPatient(Long patientId) { + return vitalSignRepository.findByPatient_ProfileIdOrderByRecordedAtDesc(patientId) + .stream().map(this::mapToDTO).collect(java.util.stream.Collectors.toList()); + } + + @Transactional(readOnly = true) + public com.securehealth.backend.dto.VitalSignDTO getLatestVitalSignByPatient(Long patientId) { + return vitalSignRepository.findFirstByPatient_ProfileIdOrderByRecordedAtDesc(patientId) + .map(this::mapToDTO) + .orElseThrow(() -> new RuntimeException("404: No vital signs found for patient")); + } + + @Transactional + public void deleteVitalSign(Long id) { + if (!vitalSignRepository.existsById(id)) { + throw new RuntimeException("404: Vital sign not found"); + } + vitalSignRepository.deleteById(id); + } + + private com.securehealth.backend.dto.VitalSignDTO mapToDTO(VitalSign v) { + com.securehealth.backend.dto.VitalSignDTO dto = new com.securehealth.backend.dto.VitalSignDTO(); + dto.setVitalSignId(v.getVitalSignId()); + dto.setPatientProfileId(v.getPatient().getProfileId()); + dto.setNurseEmail(v.getNurse().getEmail()); + dto.setBloodPressure(v.getBloodPressure()); + dto.setHeartRate(v.getHeartRate()); + dto.setTemperature(v.getTemperature()); + dto.setRespiratoryRate(v.getRespiratoryRate()); + dto.setOxygenSaturation(v.getOxygenSaturation()); + dto.setWeight(v.getWeight()); + dto.setHeight(v.getHeight()); + dto.setRecordedAt(v.getRecordedAt()); + return dto; + } } \ No newline at end of file diff --git a/backend/Backend/src/test/java/com/securehealth/backend/controller/AppointmentControllerTest.java b/backend/Backend/src/test/java/com/securehealth/backend/controller/AppointmentControllerTest.java index 240e4a44..8d32db2a 100644 --- a/backend/Backend/src/test/java/com/securehealth/backend/controller/AppointmentControllerTest.java +++ b/backend/Backend/src/test/java/com/securehealth/backend/controller/AppointmentControllerTest.java @@ -1,5 +1,6 @@ package com.securehealth.backend.controller; +import com.securehealth.backend.model.AppointmentStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.List; @@ -85,7 +86,7 @@ void getAppointmentsByPatient_ReturnsList() throws Exception { mockDto.setAppointmentId(10L); mockDto.setDoctorName("dr.house@mail.com"); mockDto.setPatientName("John Doe"); - mockDto.setStatus("SCHEDULED"); + mockDto.setStatus(AppointmentStatus.SCHEDULED); mockDto.setReasonForVisit("Routine Checkup"); when(appointmentService.getAppointmentsByPatient(1L)) @@ -100,13 +101,13 @@ void getAppointmentsByPatient_ReturnsList() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$[0].appointmentId").value(10)) // Matches the DTO .andExpect(jsonPath("$[0].doctorName").value("dr.house@mail.com")) // Matches the DTO - .andExpect(jsonPath("$[0].status").value("SCHEDULED")); // Matches the DTO + .andExpect(jsonPath("$[0].status").value(AppointmentStatus.SCHEDULED.toString())); // Matches the DTO } @Test void approveAppointment_AsAdmin_Returns200() throws Exception { // Arrange Appointment approved = new Appointment(); - approved.setStatus("SCHEDULED"); + approved.setStatus(AppointmentStatus.SCHEDULED); when(appointmentService.approveAppointment(10L)).thenReturn(approved); // Manually create the Authentication object @@ -118,7 +119,7 @@ void approveAppointment_AsAdmin_Returns200() throws Exception { .principal(auth) // <-- THIS INJECTS THE AUTHENTICATION .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("SCHEDULED")); + .andExpect(jsonPath("$.status").value(AppointmentStatus.SCHEDULED.toString())); } @Test @@ -138,7 +139,7 @@ void approveAppointment_AsPatient_Returns403() throws Exception { @Test void rejectAppointment_AsAdmin_Returns200() throws Exception { Appointment rejected = new Appointment(); - rejected.setStatus("REJECTED"); + rejected.setStatus(AppointmentStatus.REJECTED); when(appointmentService.rejectAppointment(eq(10L), any())).thenReturn(rejected); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( @@ -149,6 +150,6 @@ void rejectAppointment_AsAdmin_Returns200() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content("Not in network")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("REJECTED")); + .andExpect(jsonPath("$.status").value(AppointmentStatus.REJECTED.toString())); } } diff --git a/backend/Backend/src/test/java/com/securehealth/backend/service/AdminServiceTest.java b/backend/Backend/src/test/java/com/securehealth/backend/service/AdminServiceTest.java index 430ba201..f0fca442 100644 --- a/backend/Backend/src/test/java/com/securehealth/backend/service/AdminServiceTest.java +++ b/backend/Backend/src/test/java/com/securehealth/backend/service/AdminServiceTest.java @@ -2,6 +2,7 @@ import com.securehealth.backend.dto.AdminMetricsDTO; import com.securehealth.backend.dto.StaffDTO; +import com.securehealth.backend.model.AppointmentStatus; import com.securehealth.backend.model.Login; import com.securehealth.backend.model.Role; import com.securehealth.backend.repository.AppointmentRepository; @@ -45,7 +46,7 @@ void getDashboardMetrics_ReturnsAccurateCounts() { // Arrange when(patientProfileRepository.count()).thenReturn(150L); when(loginRepository.countByRole(Role.DOCTOR.name())).thenReturn(12L); - when(appointmentRepository.countByStatus("PENDING_APPROVAL")).thenReturn(5L); + when(appointmentRepository.countByStatus(AppointmentStatus.PENDING_APPROVAL)).thenReturn(5L); when(appointmentRepository.countTodaysAppointments(any(), any())).thenReturn(20L); // Act diff --git a/backend/Backend/src/test/java/com/securehealth/backend/service/AppointmentServiceTest.java b/backend/Backend/src/test/java/com/securehealth/backend/service/AppointmentServiceTest.java index a99479f3..748ce16d 100644 --- a/backend/Backend/src/test/java/com/securehealth/backend/service/AppointmentServiceTest.java +++ b/backend/Backend/src/test/java/com/securehealth/backend/service/AppointmentServiceTest.java @@ -2,6 +2,7 @@ import com.securehealth.backend.dto.AppointmentRequest; import com.securehealth.backend.model.Appointment; +import com.securehealth.backend.model.AppointmentStatus; import com.securehealth.backend.model.Login; import com.securehealth.backend.model.PatientProfile; import com.securehealth.backend.repository.AppointmentRepository; @@ -58,7 +59,7 @@ void setUp() { pendingAppointment = new Appointment(); pendingAppointment.setAppointmentId(10L); - pendingAppointment.setStatus("PENDING_APPROVAL"); + pendingAppointment.setStatus(AppointmentStatus.PENDING_APPROVAL); } @Test @@ -79,7 +80,7 @@ void createAppointment_SetsStatusToPending() { Appointment result = appointmentService.createAppointment(request, "patient@mail.com"); - assertEquals("PENDING_APPROVAL", result.getStatus()); + assertEquals(AppointmentStatus.PENDING_APPROVAL, result.getStatus()); verify(appointmentRepository).save(any(Appointment.class)); } @@ -110,12 +111,12 @@ void approveAppointment_ChangesStatusToScheduled() { Appointment approved = appointmentService.approveAppointment(10L); - assertEquals("SCHEDULED", approved.getStatus()); + assertEquals(AppointmentStatus.SCHEDULED, approved.getStatus()); } @Test void approveAppointment_ThrowsErrorIfNotPending() { - pendingAppointment.setStatus("SCHEDULED"); // Already scheduled + pendingAppointment.setStatus(AppointmentStatus.SCHEDULED); // Already scheduled when(appointmentRepository.findById(anyLong())).thenReturn(Optional.of(pendingAppointment)); RuntimeException exception = assertThrows(RuntimeException.class, diff --git a/backend/Backend/src/test/resources/application.properties b/backend/Backend/src/test/resources/application.properties index aa6973b9..dd6de3c2 100644 --- a/backend/Backend/src/test/resources/application.properties +++ b/backend/Backend/src/test/resources/application.properties @@ -48,3 +48,12 @@ management.metrics.export.prometheus.enabled=true management.prometheus.metrics.export.enabled=true spring.main.allow-bean-definition-overriding=true + +# ------------------------- +# APP CONFIG (ENCRYPTION & STORAGE) +# ------------------------- +app.encryption.key=dGVzdC1lbmNyeXB0aW9uLWtleS1mb3ItanVuaXQtdGVzdHM= +upload.dir=./test-uploads +backup.dir=./test-backups +archival.enabled=false +backup.enabled=false