diff --git a/web-backend/src/main/java/com/statusneo/vms/config/ValidationConfig.java b/web-backend/src/main/java/com/statusneo/vms/config/ValidationConfig.java new file mode 100644 index 0000000..950607a --- /dev/null +++ b/web-backend/src/main/java/com/statusneo/vms/config/ValidationConfig.java @@ -0,0 +1,24 @@ +package com.statusneo.vms.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + + +@Configuration +public class ValidationConfig { + + + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } + + @Bean + public MethodValidationPostProcessor methodValidationPostProcessor() { + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); + processor.setValidator(validator()); + return processor; + } +} \ No newline at end of file diff --git a/web-backend/src/main/java/com/statusneo/vms/controller/VisitorController.java b/web-backend/src/main/java/com/statusneo/vms/controller/VisitorController.java index fc4c9ec..724ca40 100644 --- a/web-backend/src/main/java/com/statusneo/vms/controller/VisitorController.java +++ b/web-backend/src/main/java/com/statusneo/vms/controller/VisitorController.java @@ -18,6 +18,7 @@ */ package com.statusneo.vms.controller; +import org.springframework.validation.FieldError; import com.statusneo.vms.cache.EmployeeNameCache; import com.statusneo.vms.dto.VerificationResult; import com.statusneo.vms.model.Visit; @@ -27,12 +28,14 @@ import com.statusneo.vms.service.GraphDirectoryService; import com.statusneo.vms.service.OtpService; import com.statusneo.vms.service.VisitService; +import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -61,6 +64,56 @@ public class VisitorController { @Autowired private EmployeeRepository employeeRepository; + @GetMapping("/") + public String home(Model model) { + model.addAttribute("visitor", new Visitor()); + return "index"; + } + + @GetMapping("/register") + public String showRegistrationForm(Model model) { + model.addAttribute("visitor", new Visitor()); + model.addAttribute("employees", employeeRepository.findAll()); + return "visitorRegistration"; + } + + @PostMapping("/register") + public String registerVisitor( + @Valid @ModelAttribute("visitor") Visitor visitor, + BindingResult bindingResult, + @RequestParam(value = "host", required = false) String host, + @RequestParam(value = "employee", required = false) String employee, + @RequestHeader(value = "HX-Request", required = false) String hxRequest, + Model model) { + + logger.info("Processing visitor registration for: {}", visitor.getEmail()); + + if (bindingResult.hasErrors()) { + logger.warn("Form validation failed with {} errors", bindingResult.getErrorCount()); + + List fieldErrors = bindingResult.getFieldErrors(); + model.addAttribute("fieldErrors", fieldErrors); + model.addAttribute("visitor", visitor); + model.addAttribute("employees", employeeRepository.findAll()); + + if (hxRequest != null && hxRequest.equals("true")) { + return "fragments/validation-errors"; + } + return "visitorRegistration"; + } + + resolveAndSetHost(visitor, host, employee); + + Visit savedVisit = visitService.registerVisit(visitor); + model.addAttribute("visitId", savedVisit.getId()); + model.addAttribute("visit", savedVisit); + + if (hxRequest != null && hxRequest.equals("true")) { + return "fragments/otp-modal"; + } + + return "visitorRegistrationResult"; + } @GetMapping("/report") public ResponseEntity getReport(@RequestParam String period) { @@ -75,16 +128,10 @@ public ResponseEntity getReport(@RequestParam String period) { return ResponseEntity.ok(visit); } - // @RequestMapping("/error") - public String handleError() { - return "Custom error page!"; - } - - @GetMapping("/") - public String home() { - return "index"; // Looks for src/main/resources/templates/simple.html - } - + // @RequestMapping("/error") + // public String handleError() { + // return "Custom error page!"; + // } @GetMapping("/refresh-employee-cache") public ResponseEntity refreshEmployeeCache() { @@ -92,29 +139,6 @@ public ResponseEntity refreshEmployeeCache() { return ResponseEntity.ok("Cache refreshed"); } - @PostMapping("/register") - public String registerVisitor(@ModelAttribute Visitor visitor, - @RequestParam(value = "host", required = false) String host, - @RequestParam(value = "employee", required = false) String employee, - @RequestHeader(value = "HX-Request", required = false) String hxRequest, - Model model) { - // prefer explicit host id, fall back to name - resolveAndSetHost(visitor, host, employee); - Visit savedVisit = visitService.registerVisit(visitor); - model.addAttribute("visitId", savedVisit.getId()); - - // If it's an HTMX request, just return the modal fragment - if (hxRequest != null && hxRequest.equals("true")) { - // JTE doesn't use Thymeleaf fragment syntax ("::"). Return the template name - // that corresponds to src/main/jte/fragments/otp-modal.jte - return "fragments/otp-modal"; - } - - // For regular form submission (fallback) - return "otp-modal"; - } - - // Updated to return Object so we can return ResponseEntity for HTMX redirects @PostMapping("/confirm-visit") public Object confirmVisit(@RequestParam("visitId") Long visitId, @RequestParam("otpCode") String otpCode, @@ -124,48 +148,37 @@ public Object confirmVisit(@RequestParam("visitId") Long visitId, model.addAttribute("result", result); model.addAttribute("visitId", visitId); - // If it's an HTMX request, return a fragment or an HX-Redirect when attempts exhausted if (hxRequest != null && hxRequest.equals("true")) { if (result.success()) { - // Pass the visit to get visitor details for success message Visit visit = visitRepository.findById(visitId) .orElseThrow(() -> new IllegalArgumentException("Visit not found")); model.addAttribute("visit", visit); - // Return the JTE template for success message return "fragments/success-message"; } else { - // If no more reattempts allowed, tell HTMX to redirect to the entry page if (!result.reattempt()) { return ResponseEntity.ok().header("HX-Redirect", "/").build(); } - // Auto-resend OTP when a failed attempt occurred and reattempts remain Visit visit = visitRepository.findById(visitId) .orElseThrow(() -> new IllegalArgumentException("Visit not found")); - VerificationResult resendResult = otpService.generateOtp(visit, false); // don't reset attempt counter + VerificationResult resendResult = otpService.generateOtp(visit, false); - // Decide the message to show in the modal: prefer an explicit resend message when OTP re-sent successfully if (resendResult.success()) { model.addAttribute("serverMessage", "Invalid OTP. A new OTP has been sent to your email."); } else { - // If resend failed (cooldown or limit), show that message instead model.addAttribute("serverMessage", resendResult.message()); } - // Re-show the otp modal with an error message so HTMX swaps it in place return "fragments/otp-modal"; } } - // For regular form submission (fallback): if (result.success()) { return "confirmation-modal"; } else if (!result.reattempt()) { - // Attempts exhausted: redirect to blank visitor entry form return "redirect:/"; } else { - // Auto-resend for non-HTMX fallback as well Visit visit = visitRepository.findById(visitId) .orElseThrow(() -> new IllegalArgumentException("Visit not found")); @@ -176,14 +189,15 @@ public Object confirmVisit(@RequestParam("visitId") Long visitId, model.addAttribute("serverMessage", resendResult.message()); } - // Re-show otp page with message for non-HTMX fallback model.addAttribute("visitId", visitId); return "otp-modal"; } } @PostMapping("/resend-otp") - public String resendOtp(@RequestParam("visitId") Long visitId, Model model) { + public String resendOtp(@RequestParam("visitId") Long visitId, + @RequestHeader(value = "HX-Request", required = false) String hxRequest, + Model model) { Visit visit = visitRepository.findById(visitId) .orElseThrow(() -> new IllegalArgumentException("Visit not found")); @@ -192,8 +206,11 @@ public String resendOtp(@RequestParam("visitId") Long visitId, Model model) { model.addAttribute("result", result); model.addAttribute("visitId", visitId); model.addAttribute("serverMessage", result.message()); - // For HTMX flows this should probably return the otp modal again so the UI is updated. - return "fragments/otp-modal"; + + if (hxRequest != null && hxRequest.equals("true")) { + return "fragments/otp-modal"; + } + return "otp-modal"; } /** @@ -202,20 +219,17 @@ public String resendOtp(@RequestParam("visitId") Long visitId, Model model) { private void resolveAndSetHost(Visitor visitor, String hostIdStr, String employeeStr) { if (visitor == null) return; - // If explicit host id provided, prefer it if (hostIdStr != null && !hostIdStr.isBlank()) { try { Long id = Long.valueOf(hostIdStr.trim()); employeeRepository.findById(id).ifPresent(visitor::setHost); return; } catch (NumberFormatException ignored) { - // fall through to name resolution } } - // Fall back to name-based resolution if provided if (employeeStr == null || employeeStr.isBlank()) return; String trimmed = employeeStr.trim(); employeeRepository.findByNameIgnoreCase(trimmed).ifPresent(visitor::setHost); } -} +} \ No newline at end of file diff --git a/web-backend/src/main/java/com/statusneo/vms/exception/GlobalExceptionHandler.java b/web-backend/src/main/java/com/statusneo/vms/exception/GlobalExceptionHandler.java index f1abf22..51ee498 100644 --- a/web-backend/src/main/java/com/statusneo/vms/exception/GlobalExceptionHandler.java +++ b/web-backend/src/main/java/com/statusneo/vms/exception/GlobalExceptionHandler.java @@ -1,68 +1,128 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ package com.statusneo.vms.exception; import gg.jte.TemplateException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.ui.Model; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; import java.util.NoSuchElementException; +import java.util.stream.Collectors; + @ControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleMethodArgumentNotValid(MethodArgumentNotValidException ex, Model model) { + String errorMessage = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + logger.warn("Method argument validation failed: {}", errorMessage); + model.addAttribute("error", "Validation failed: " + errorMessage); + model.addAttribute("fieldErrors", ex.getBindingResult().getFieldErrors()); + + return "error/400"; + } + + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleBindException(BindException ex, Model model) { + String errorMessage = ex.getFieldErrors() + .stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + logger.warn("Binding error: {}", errorMessage); + model.addAttribute("error", "Form binding failed: " + errorMessage); + model.addAttribute("fieldErrors", ex.getFieldErrors()); + + return "error/400"; + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleMissingParams(MissingServletRequestParameterException ex, Model model) { + logger.warn("Missing required parameter: {}", ex.getParameterName()); + model.addAttribute("error", "Missing required parameter: " + ex.getParameterName()); + return "error/400"; + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleTypeMismatch(MethodArgumentTypeMismatchException ex, Model model) { + logger.warn("Type mismatch for parameter '{}': expected {}", + ex.getName(), ex.getRequiredType()); + model.addAttribute("error", + String.format("Invalid value for parameter '%s'. Expected type: %s", + ex.getName(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown")); + return "error/400"; + } + + @ExceptionHandler(NoSuchElementException.class) - public String handleNoSuchElement(NoSuchElementException ex, Model model) { - logger.warn("Resource not found", ex); - model.addAttribute("error", "The requested resource was not found."); - return "404"; + @ResponseStatus(HttpStatus.NOT_FOUND) + public String handleNoSuchElement(NoSuchElementException ex, HttpServletRequest request) { + logger.warn("Resource not found: {}", ex.getMessage()); + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.NOT_FOUND.value()); + return "forward:/error/404.html"; } - @ExceptionHandler(DataIntegrityViolationException.class) - public String handleDataIntegrityViolation(DataIntegrityViolationException ex, Model model) { - logger.error("Data integrity violation", ex); - model.addAttribute("error", "A data validation error occurred. Please check your input."); + + @ExceptionHandler({DataIntegrityViolationException.class, IllegalArgumentException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleBadRequestExceptions(Exception ex, Model model) { + logger.warn("Bad request - {}: {}", ex.getClass().getSimpleName(), ex.getMessage()); + model.addAttribute("error", "Invalid request: " + ex.getMessage()); return "error/400"; } - @ExceptionHandler(IllegalArgumentException.class) - public String handleIllegalArgument(IllegalArgumentException ex, Model model) { - logger.warn("Invalid argument", ex); - model.addAttribute("error", "Invalid input provided."); + + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleConstraintViolation(ConstraintViolationException ex, Model model) { + String errorMessage = ex.getConstraintViolations() + .stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .collect(Collectors.joining(", ")); + + logger.warn("Constraint violation: {}", errorMessage); + model.addAttribute("error", "Invalid input: " + errorMessage); return "error/400"; } + @ExceptionHandler(TemplateException.class) - public String handleTemplateException(TemplateException ex, Model model) { - logger.error("Template rendering failed", ex); - model.addAttribute("error", "A technical error occurred while rendering the page."); - return "error/500"; + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String handleTemplateException(TemplateException ex, HttpServletRequest request) { + logger.error("Template rendering failed: {}", ex.getMessage(), ex); + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.INTERNAL_SERVER_ERROR.value()); + return "forward:/error/500.html"; } @ExceptionHandler(Exception.class) - public String handleGeneralException(Exception ex, Model model) { - logger.error("Unexpected error occurred", ex); - model.addAttribute("error", "Something went wrong. Please try again later."); - return "error/500"; + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String handleGeneralException(Exception ex, HttpServletRequest request) { + logger.error("Unexpected error occurred - {}: {}", + ex.getClass().getSimpleName(), ex.getMessage(), ex); + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.INTERNAL_SERVER_ERROR.value()); + return "forward:/error/500.html"; } -} +} \ No newline at end of file diff --git a/web-backend/src/main/java/com/statusneo/vms/model/Visitor.java b/web-backend/src/main/java/com/statusneo/vms/model/Visitor.java index 4953d52..3f9cdbb 100644 --- a/web-backend/src/main/java/com/statusneo/vms/model/Visitor.java +++ b/web-backend/src/main/java/com/statusneo/vms/model/Visitor.java @@ -43,7 +43,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import java.time.LocalDateTime; @@ -56,9 +56,6 @@ @Table(name = "visitor") public class Visitor { - private static final String PHONE_NUMBER_REGEX = "^\\d{10}$"; - - /** * Unique identifier for the visitor. */ @@ -69,29 +66,27 @@ public class Visitor { /** * Full name of the visitor. */ - private String name; + public String name; - /** - * Contact phone number of the visitor. - */ - @NotNull(message = "Phone number cannot be null") - @Pattern(regexp = "^\\d{10}$", message = "Phone number must be exactly 10 digits") - private String phoneNumber; + @Column(name = "phone_number") + @NotBlank(message = "Phone number is required") + @Pattern(regexp = "^[0-9]+$", message = "Phone number must contain only digits") + public String phoneNumber; /** * Email address of the visitor, used for communication and OTP verification. */ - private String email; + public String email; /** * Physical address of the visitor. */ - private String address; + public String address; /** * Company of the visitor. */ - private String company; + public String company; /** * Path to the visitor's profile picture stored in the system. @@ -99,16 +94,14 @@ public class Visitor { @Column(name = "picture_path") private String picturePath; - @Column(name = "laptop_number") - private String laptop; + public String laptop; @Column(name = "valid_govt_id") - private String validGovtId; - + public String validGovtId; @Column(name = "visit_purpose") - private String visitPurpose; + public String visitPurpose; @ManyToOne @JoinColumn(name = "host_id", referencedColumnName = "id") @@ -161,7 +154,7 @@ protected void onCreate() { this.createdAt = LocalDateTime.now(); } - public LocalDateTime getCreatedAt(){ + public LocalDateTime getCreatedAt() { return createdAt; } @@ -188,6 +181,7 @@ public String getPhoneNumber() { public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } + public String getEmail() { return email; } @@ -215,6 +209,7 @@ public void setCompany(String company) { public String getPicturePath() { return picturePath; } + public void setPicturePath(String picturePath) { this.picturePath = picturePath; } @@ -227,14 +222,21 @@ public void setLaptop(String laptop) { this.laptop = laptop; } - public String getValidGovtId() { return validGovtId; } - - public void setValidGovtId(String validGovtId) { this.validGovtId = validGovtId; } + public String getValidGovtId() { + return validGovtId; + } - public String getVisitPurpose() { return visitPurpose; } + public void setValidGovtId(String validGovtId) { + this.validGovtId = validGovtId; + } - public void setVisitPurpose(String visitPurpose) { this.visitPurpose = visitPurpose; } + public String getVisitPurpose() { + return visitPurpose; + } + public void setVisitPurpose(String visitPurpose) { + this.visitPurpose = visitPurpose; + } @Override public String toString() { @@ -246,10 +248,9 @@ public String toString() { ", address='" + address + '\'' + ", company='" + company + '\'' + ", picturePath='" + picturePath + '\'' + - ", laptop_number='" + laptop + '\'' + - ", valid_govt_id='" + validGovtId + '\'' + - ", visit_purpose='" + visitPurpose + '\'' + + ", laptop='" + laptop + '\'' + + ", validGovtId='" + validGovtId + '\'' + + ", visitPurpose='" + visitPurpose + '\'' + '}'; } - } \ No newline at end of file diff --git a/web-backend/src/main/jte/error/400.jte b/web-backend/src/main/jte/error/400.jte index f922a54..4cea066 100644 --- a/web-backend/src/main/jte/error/400.jte +++ b/web-backend/src/main/jte/error/400.jte @@ -1,8 +1,94 @@ - -Bad Request +@import org.springframework.validation.FieldError +@import java.util.List + +@param String error = "Bad Request" +@param List fieldErrors = null + + + + + + + 400 - Bad Request + + -

Invalid Request

-

@error

-Back to Home +
+

400 - Bad Request

+ +
+ ${error} +
+ + @if(fieldErrors != null && !fieldErrors.isEmpty()) +

Validation Errors:

+
    + @for(FieldError fieldError : fieldErrors) +
  • + ${fieldError.getField()}: + ${fieldError.getDefaultMessage()} +
  • + @endfor +
+ @endif + + Return to Home +
\ No newline at end of file diff --git a/web-backend/src/main/jte/error/404.jte b/web-backend/src/main/jte/error/404.jte deleted file mode 100644 index f51a3e6..0000000 --- a/web-backend/src/main/jte/error/404.jte +++ /dev/null @@ -1,8 +0,0 @@ - -404 Not Found - -

Oops! Page Not Found

-

@error

-Back to Home - - \ No newline at end of file diff --git a/web-backend/src/main/jte/error/500.jte b/web-backend/src/main/jte/error/500.jte deleted file mode 100644 index ead84d9..0000000 --- a/web-backend/src/main/jte/error/500.jte +++ /dev/null @@ -1,8 +0,0 @@ - -Server Error - -

Internal Server Error

-

@error

-Back to Home - - \ No newline at end of file diff --git a/web-backend/src/main/jte/index.jte b/web-backend/src/main/jte/index.jte index 8af6625..a0a84ce 100644 --- a/web-backend/src/main/jte/index.jte +++ b/web-backend/src/main/jte/index.jte @@ -1,4 +1,11 @@ +@import org.springframework.validation.FieldError +@import java.util.List +@import com.statusneo.vms.model.Visitor @import com.statusneo.vms.enums.VisitPurpose + +@param Visitor visitor = new com.statusneo.vms.model.Visitor() +@param List fieldErrors = null + @@ -8,13 +15,17 @@ + + +
+
@@ -23,8 +34,28 @@

Please fill in your details to register your visit

+
+ + @if(fieldErrors != null && !fieldErrors.isEmpty()) +
+
+ + + +
+

Please correct the following errors:

+
    + @for(FieldError error : fieldErrors) +
  • ${error.getField()}: ${error.getDefaultMessage()}
  • + @endfor +
+
+
+
+ @endif +
+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
-
+ +
+ +
+ +
- +
-
- - +
+ +
By checking in, you agree to our visitor policies and terms of service.
@@ -196,32 +223,79 @@

Visitor Check-in

- - - - - - - - - - - + // Hide dropdown when clicking outside + document.addEventListener('click', function(event) { + const hostSearch = document.getElementById('host-search'); + const hostResults = document.getElementById('host-results'); + if (hostSearch && hostResults && !hostSearch.contains(event.target) && !hostResults.contains(event.target)) { + hostResults.classList.add('hidden'); + } + }); - - \ No newline at end of file + + \ No newline at end of file