diff --git a/web-backend/src/main/java/com/statusneo/vms/cache/EmployeeNameCache.java b/web-backend/src/main/java/com/statusneo/vms/cache/EmployeeNameCache.java index b529157..4d1042b 100644 --- a/web-backend/src/main/java/com/statusneo/vms/cache/EmployeeNameCache.java +++ b/web-backend/src/main/java/com/statusneo/vms/cache/EmployeeNameCache.java @@ -3,10 +3,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Component; @@ -22,6 +25,7 @@ public class EmployeeNameCache { private static final int MAX_SUGGESTIONS = 10; private final TrieNode root = new TrieNode(); + private final Map nameToEmployeeMap = new HashMap<>(); private final EmployeeRepository employeeRepository; public EmployeeNameCache(EmployeeRepository employeeRepository) { @@ -35,6 +39,7 @@ public void initializeCache() { for (Employee employee : allEmployees) { if (employee.getName() != null && !employee.getName().isBlank()) { insert(employee.getName()); + nameToEmployeeMap.put(employee.getName(), employee); } } } @@ -49,6 +54,12 @@ public void insert(String name) { node.addOriginal(name); } + public void insertEmployee(Employee employee) { + if (employee == null || employee.getName() == null || employee.getName().isBlank()) return; + insert(employee.getName()); + nameToEmployeeMap.put(employee.getName(), employee); + } + public List getEmployeeNamesByPrefix(String prefix) { if (prefix == null) prefix = ""; TrieNode node = root; @@ -57,11 +68,9 @@ public List getEmployeeNamesByPrefix(String prefix) { if (node == null) return Collections.emptyList(); } - // Use LinkedHashSet to preserve insertion order and avoid duplicates Set results = new LinkedHashSet<>(); collectNames(node, results); - // If prefix is empty, we may have many results - limit to MAX_SUGGESTIONS List list = new ArrayList<>(results); if (list.size() > MAX_SUGGESTIONS) { return list.subList(0, MAX_SUGGESTIONS); @@ -69,6 +78,18 @@ public List getEmployeeNamesByPrefix(String prefix) { return list; } + public List getEmployeesByPrefix(String prefix) { + List names = getEmployeeNamesByPrefix(prefix); + return names.stream() + .map(name -> nameToEmployeeMap.get(name)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + public Employee getEmployeeByName(String name) { + return nameToEmployeeMap.get(name); + } + private void collectNames(TrieNode node, Set results) { if (results.size() >= MAX_SUGGESTIONS) return; if (node.isEndOfWord()) { @@ -87,6 +108,7 @@ private void collectNames(TrieNode node, Set results) { public void clear() { root.getChildren().clear(); + nameToEmployeeMap.clear(); } public void bulkInsert(Collection names) { @@ -96,4 +118,24 @@ public void bulkInsert(Collection names) { } } } + public void bulkInsertEmployees(Collection employees) { + for (Employee employee : employees) { + if (employee != null && employee.getName() != null && !employee.getName().isBlank()) { + insertEmployee(employee); + } + } + } + + public void updateEmployee(Employee employee) { + if (employee == null || employee.getName() == null || employee.getName().isBlank()) return; + + nameToEmployeeMap.put(employee.getName(), employee); + } + + public Map getCacheStats() { + Map stats = new HashMap<>(); + stats.put("totalEmployees", nameToEmployeeMap.size()); + stats.put("cacheInitialized", !nameToEmployeeMap.isEmpty()); + return stats; + } } \ No newline at end of file diff --git a/web-backend/src/main/java/com/statusneo/vms/controller/HomeController.java b/web-backend/src/main/java/com/statusneo/vms/controller/HomeController.java index 4fbaa89..5f672cc 100644 --- a/web-backend/src/main/java/com/statusneo/vms/controller/HomeController.java +++ b/web-backend/src/main/java/com/statusneo/vms/controller/HomeController.java @@ -18,6 +18,7 @@ */ package com.statusneo.vms.controller; +import com.statusneo.vms.cache.EmployeeNameCache; import com.statusneo.vms.model.Employee; import com.statusneo.vms.service.EmployeeService; import org.springframework.stereotype.Controller; @@ -28,11 +29,13 @@ import java.util.List; @Controller -public class HomeController { +public class HomeController { private final EmployeeService employeeService; + private final EmployeeNameCache employeeNameCache; - public HomeController(EmployeeService employeeService) { + public HomeController(EmployeeService employeeService, EmployeeNameCache employeeNameCache) { this.employeeService = employeeService; + this.employeeNameCache = employeeNameCache; } @GetMapping("/search-employees") @@ -40,7 +43,6 @@ public String employees(@RequestParam(value = "hostSearch", required = false) St @RequestParam(value = "employee", required = false) String employee, @RequestParam(value = "query", required = false) String query, Model model) { - // Prefer hostSearch (used by index.jte), then employee, then query String q = (hostSearch != null && !hostSearch.isBlank()) ? hostSearch : ((employee != null && !employee.isBlank()) ? employee : (query == null ? "" : query)); @@ -48,8 +50,10 @@ public String employees(@RequestParam(value = "hostSearch", required = false) St return "employees"; } - List employees = employeeService.searchEmployeesByName(q); - model.addAttribute("employees", employees); + List employees = employeeNameCache.getEmployeesByPrefix(q); + model.addAttribute("hosts", employees); return "employees"; } + } + 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 391fba7..fc4c9ec 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 @@ -75,7 +75,7 @@ public ResponseEntity getReport(@RequestParam String period) { return ResponseEntity.ok(visit); } -// @RequestMapping("/error") + // @RequestMapping("/error") public String handleError() { return "Custom error page!"; } @@ -86,14 +86,6 @@ public String home() { } - @GetMapping("/search") - public String searchEmployees(@RequestParam("employee") String query, Model model) { - logger.info("Received search request for employee: {}", query); - List names = employeeNameCache.getEmployeeNamesByPrefix(query == null ? "" : query); - model.addAttribute("employees", names); - return "employeeSearchResults"; - } - @GetMapping("/refresh-employee-cache") public ResponseEntity refreshEmployeeCache() { employeeNameCache.initializeCache(); @@ -102,15 +94,15 @@ public ResponseEntity refreshEmployeeCache() { @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) { + @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 @@ -125,9 +117,9 @@ public String registerVisitor(@ModelAttribute Visitor visitor, // 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, - @RequestHeader(value = "HX-Request", required = false) String hxRequest, - Model model) { + @RequestParam("otpCode") String otpCode, + @RequestHeader(value = "HX-Request", required = false) String hxRequest, + Model model) { VerificationResult result = visitService.confirmVisit(visitId, otpCode); model.addAttribute("result", result); model.addAttribute("visitId", visitId); @@ -137,7 +129,7 @@ public Object confirmVisit(@RequestParam("visitId") Long visitId, if (result.success()) { // Pass the visit to get visitor details for success message Visit visit = visitRepository.findById(visitId) - .orElseThrow(() -> new IllegalArgumentException("Visit not found")); + .orElseThrow(() -> new IllegalArgumentException("Visit not found")); model.addAttribute("visit", visit); // Return the JTE template for success message return "fragments/success-message"; @@ -149,7 +141,7 @@ public Object confirmVisit(@RequestParam("visitId") Long visitId, // Auto-resend OTP when a failed attempt occurred and reattempts remain Visit visit = visitRepository.findById(visitId) - .orElseThrow(() -> new IllegalArgumentException("Visit not found")); + .orElseThrow(() -> new IllegalArgumentException("Visit not found")); VerificationResult resendResult = otpService.generateOtp(visit, false); // don't reset attempt counter @@ -165,7 +157,7 @@ public Object confirmVisit(@RequestParam("visitId") Long visitId, return "fragments/otp-modal"; } } - + // For regular form submission (fallback): if (result.success()) { return "confirmation-modal"; diff --git a/web-backend/src/test/java/com/statusneo/vms/cache/EmployeeNameCacheTest.java b/web-backend/src/test/java/com/statusneo/vms/cache/EmployeeNameCacheTest.java new file mode 100644 index 0000000..06006ba --- /dev/null +++ b/web-backend/src/test/java/com/statusneo/vms/cache/EmployeeNameCacheTest.java @@ -0,0 +1,129 @@ +package com.statusneo.vms.cache; + +import com.statusneo.vms.model.Employee; +import com.statusneo.vms.repository.EmployeeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.*; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EmployeeNameCacheTest { + + @Mock + private EmployeeRepository employeeRepository; + + private EmployeeNameCache cache; + + @BeforeEach + void setup() { + cache = new EmployeeNameCache(employeeRepository); + } + + private Employee emp(String name, String email) { + Employee e = new Employee(); + e.setName(name); + e.setEmail(email); + return e; + } + + @Test + void initializeCache_populates_from_repository_and_searches_by_prefix() { + List list = Arrays.asList( + emp("Alice Johnson", "alice@example.com"), + emp("Bob Smith", "bob@example.com"), + emp("Alicia Keys", "alicia@example.com") + ); + when(employeeRepository.findAll()).thenReturn(list); + + cache.initializeCache(); + + List aNames = cache.getEmployeeNamesByPrefix("Ali"); + assertTrue(aNames.stream().anyMatch(s -> s.equals("Alice Johnson"))); + assertTrue(aNames.stream().anyMatch(s -> s.equals("Alicia Keys"))); + + List employees = cache.getEmployeesByPrefix("Bob"); + assertEquals(1, employees.size()); + assertEquals("Bob Smith", employees.get(0).getName()); + + Employee byName = cache.getEmployeeByName("Alice Johnson"); + assertNotNull(byName); + assertEquals("alice@example.com", byName.getEmail()); + + Map stats = cache.getCacheStats(); + assertEquals(3, ((Integer) stats.get("totalEmployees")).intValue()); + assertEquals(true, stats.get("cacheInitialized")); + } + + @Test + void getEmployeeNamesByPrefix_limits_to_max_suggestions() { + // create more than MAX_SUGGESTIONS employees with same prefix "Emp" + List many = new ArrayList<>(); + IntStream.range(0, 20).forEach(i -> many.add(emp("EmpUser" + i, "u"+i+"@example.com"))); + when(employeeRepository.findAll()).thenReturn(many); + + cache.initializeCache(); + + List results = cache.getEmployeeNamesByPrefix("Emp"); + // MAX_SUGGESTIONS is 10 in implementation + assertTrue(results.size() <= 10); + assertEquals(10, results.size()); + } + + @Test + void clear_removes_all_entries() { + when(employeeRepository.findAll()).thenReturn(Arrays.asList(emp("One","one@example.com"))); + cache.initializeCache(); + + Map statsBefore = cache.getCacheStats(); + assertEquals(1, ((Integer) statsBefore.get("totalEmployees")).intValue()); + + cache.clear(); + Map statsAfter = cache.getCacheStats(); + assertEquals(0, ((Integer) statsAfter.get("totalEmployees")).intValue()); + assertEquals(false, statsAfter.get("cacheInitialized")); + } + + @Test + void insert_and_update_employee_behaviour() { + Employee john = emp("John Doe", "john@old.example"); + cache.insertEmployee(john); + + Employee got = cache.getEmployeeByName("John Doe"); + assertNotNull(got); + assertEquals("john@old.example", got.getEmail()); + + // update with new email, same name + Employee johnUpdated = emp("John Doe", "john@new.example"); + cache.updateEmployee(johnUpdated); + + Employee gotAfter = cache.getEmployeeByName("John Doe"); + assertNotNull(gotAfter); + assertEquals("john@new.example", gotAfter.getEmail()); + } + + @Test + void bulkInsertEmployees_adds_many_entries() { + List employees = Arrays.asList( + emp("A", "a@example.com"), + emp("B", "b@example.com"), + emp("C", "c@example.com") + ); + cache.clear(); + cache.bulkInsertEmployees(employees); + + Map stats = cache.getCacheStats(); + assertEquals(3, ((Integer) stats.get("totalEmployees")).intValue()); + + List found = cache.getEmployeesByPrefix(""); + // should return up to available employees + assertTrue(found.size() >= 3); + } +} \ No newline at end of file