Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,6 +25,7 @@ public class EmployeeNameCache {
private static final int MAX_SUGGESTIONS = 10;

private final TrieNode root = new TrieNode();
private final Map<String, Employee> nameToEmployeeMap = new HashMap<>();
private final EmployeeRepository employeeRepository;

public EmployeeNameCache(EmployeeRepository employeeRepository) {
Expand All @@ -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);
}
}
}
Expand All @@ -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<String> getEmployeeNamesByPrefix(String prefix) {
if (prefix == null) prefix = "";
TrieNode node = root;
Expand All @@ -57,18 +68,28 @@ public List<String> getEmployeeNamesByPrefix(String prefix) {
if (node == null) return Collections.emptyList();
}

// Use LinkedHashSet to preserve insertion order and avoid duplicates
Set<String> results = new LinkedHashSet<>();
collectNames(node, results);

// If prefix is empty, we may have many results - limit to MAX_SUGGESTIONS
List<String> list = new ArrayList<>(results);
if (list.size() > MAX_SUGGESTIONS) {
return list.subList(0, MAX_SUGGESTIONS);
}
return list;
}

public List<Employee> getEmployeesByPrefix(String prefix) {
List<String> 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<String> results) {
if (results.size() >= MAX_SUGGESTIONS) return;
if (node.isEndOfWord()) {
Expand All @@ -87,6 +108,7 @@ private void collectNames(TrieNode node, Set<String> results) {

public void clear() {
root.getChildren().clear();
nameToEmployeeMap.clear();
}

public void bulkInsert(Collection<String> names) {
Expand All @@ -96,4 +118,24 @@ public void bulkInsert(Collection<String> names) {
}
}
}
public void bulkInsertEmployees(Collection<Employee> 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<String, Object> getCacheStats() {
Map<String, Object> stats = new HashMap<>();
stats.put("totalEmployees", nameToEmployeeMap.size());
stats.put("cacheInitialized", !nameToEmployeeMap.isEmpty());
return stats;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,28 +29,31 @@
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")
public String employees(@RequestParam(value = "hostSearch", required = false) String hostSearch,
@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));

if (q.isBlank()) {
return "employees";
}

List<Employee> employees = employeeService.searchEmployeesByName(q);
model.addAttribute("employees", employees);
List<Employee> employees = employeeNameCache.getEmployeesByPrefix(q);
model.addAttribute("hosts", employees);
return "employees";
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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!";
}
Expand All @@ -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<String> names = employeeNameCache.getEmployeeNamesByPrefix(query == null ? "" : query);
model.addAttribute("employees", names);
return "employeeSearchResults";
}

@GetMapping("/refresh-employee-cache")
public ResponseEntity<String> refreshEmployeeCache() {
employeeNameCache.initializeCache();
Expand All @@ -102,15 +94,15 @@ public ResponseEntity<String> 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
Expand All @@ -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);
Expand All @@ -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";
Expand All @@ -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

Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Employee> 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<String> aNames = cache.getEmployeeNamesByPrefix("Ali");
assertTrue(aNames.stream().anyMatch(s -> s.equals("Alice Johnson")));
assertTrue(aNames.stream().anyMatch(s -> s.equals("Alicia Keys")));

List<Employee> 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<String, Object> 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<Employee> 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<String> 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<String, Object> statsBefore = cache.getCacheStats();
assertEquals(1, ((Integer) statsBefore.get("totalEmployees")).intValue());

cache.clear();
Map<String, Object> 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<Employee> employees = Arrays.asList(
emp("A", "a@example.com"),
emp("B", "b@example.com"),
emp("C", "c@example.com")
);
cache.clear();
cache.bulkInsertEmployees(employees);

Map<String, Object> stats = cache.getCacheStats();
assertEquals(3, ((Integer) stats.get("totalEmployees")).intValue());

List<Employee> found = cache.getEmployeesByPrefix("");
// should return up to available employees
assertTrue(found.size() >= 3);
}
}
Loading