diff --git a/src/main/java/com/voidsoftware/map02/Map02Application.java b/src/main/java/com/voidsoftware/map02/Map02Application.java new file mode 100644 index 0000000..ea8375f --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/Map02Application.java @@ -0,0 +1,16 @@ +package com.voidsoftware.map02; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication + +@EnableScheduling +public class Map02Application { + + public static void main(String[] args) { + SpringApplication.run(Map02Application.class, args); + } + +} diff --git a/src/main/java/com/voidsoftware/map02/client/MedMapClient.java b/src/main/java/com/voidsoftware/map02/client/MedMapClient.java new file mode 100644 index 0000000..71e1091 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/client/MedMapClient.java @@ -0,0 +1,258 @@ +package com.voidsoftware.map02.client; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.entities.PrescriptionSchedule; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.dto.CompanyDTO; +import com.voidsoftware.map02.models.dto.PatientDTO; +import com.voidsoftware.map02.models.dto.ProfessionalDTO; +import com.voidsoftware.map02.models.inout.*; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.UUID; + +/** + * Typed HTTP client for the MedMap REST API. + * Manages the JSESSIONID cookie automatically after login. + * Usage: + * var client = new MedMapClient("http://localhost:8080"); + * client.login("user@example.com", "password"); + * var patient = client.createPatient(req); + */ +public class MedMapClient { + + private final String baseUrl; + private final HttpClient http; + private final ObjectMapper mapper; + + /** + * Last known user-id / role received from the server, exposed for caller convenience. + */ + private LoginResponse session; + + public MedMapClient(String baseUrl) { + this.baseUrl = baseUrl.replaceAll("/$", ""); + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); + this.http = HttpClient.newBuilder() + .cookieHandler(cookieManager) + .build(); + this.mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + // ── Authentication ──────────────────────────────────────────────────────── + + public LoginResponse signup(String email, String password, Role role) + throws IOException, InterruptedException { + SignupRequest req = new SignupRequest(); + req.setEmail(email); + req.setPassword(password); + req.setRole(role); + session = post("/api/auth/signup", req, LoginResponse.class, 201); + return session; + } + + public LoginResponse login(String email, String password) + throws IOException, InterruptedException { + LoginRequest req = new LoginRequest(); + req.setEmail(email); + req.setPassword(password); + session = post("/api/auth/login", req, LoginResponse.class, 200); + return session; + } + + /** + * Returns the authenticated user info from the last login/signup call. + */ + public LoginResponse getSession() { + return session; + } + + // ── Companies ───────────────────────────────────────────────────────────── + + public CompanyDTO createCompany(CreateCompanyRequest req) + throws IOException, InterruptedException { + return post("/api/companies", req, CompanyDTO.class, 201); + } + + public ListOfCompaniesResponse listCompanies() + throws IOException, InterruptedException { + return get("/api/companies", ListOfCompaniesResponse.class, 200); + } + + // ── Professionals ───────────────────────────────────────────────────────── + + public ProfessionalDTO createProfessional(CreateProfessionalRequest req) + throws IOException, InterruptedException { + return post("/api/professionals", req, ProfessionalDTO.class, 201); + } + + public ListOfProfessionalsResponse listProfessionals(ListProfessionalsRequest req) + throws IOException, InterruptedException { + return post("/api/professionals/list", req, ListOfProfessionalsResponse.class, 200); + } + + // ── Patients ────────────────────────────────────────────────────────────── + + public PatientDTO createPatient(CreatePatientRequest req) + throws IOException, InterruptedException { + return post("/api/patients", req, PatientDTO.class, 201); + } + + public PatientDTO getPatient(UUID id) + throws IOException, InterruptedException { + return get("/api/patients/" + id, PatientDTO.class, 200); + } + + public ListOfMedicationsResponse listMedications() + throws IOException, InterruptedException { + return get("/api/medication/list", ListOfMedicationsResponse.class, 200); + } + + public ListOfPatientsResponse listPatients(ListPatientsRequest req) + throws IOException, InterruptedException { + return post("/api/patients/list", req, ListOfPatientsResponse.class, 200); + } + + public Prescription createPrescription(CreatePrescriptionRequest req) + throws IOException, InterruptedException { + return post("/api/prescriptions/create", req, Prescription.class, 201); + } + + public PrescriptionSchedule changePrescriptionSchedule(ChangePrescriptionScheduleRequest req) + throws IOException, InterruptedException { + return put("/api/prescriptions/schedule", req, PrescriptionSchedule.class, 200); + } + + public List listPrescriptions(ListPrescriptionsRequest req) + throws IOException, InterruptedException { + return postList("/api/prescriptions/list", req, new TypeReference<>() { + }, 200); + } + + // ── Doses ───────────────────────────────────────────────────────────────── + + public List generateDoses(GenerateDosesRequest req) + throws IOException, InterruptedException { + return postList("/api/doses/generate", req, new TypeReference<>() { + }, 200); + } + + public List generateDosesAll(GenerateDosesRequest req) + throws IOException, InterruptedException { + return postList("/api/doses/generate/all", req, new TypeReference<>() { + }, 200); + } + + // ── Core HTTP helpers ───────────────────────────────────────────────────── + + private T post(String path, Object body, Class responseType, int expectedStatus) + throws IOException, InterruptedException { + return send("POST", path, body, responseType, expectedStatus); + } + + private List postList(String path, Object body, TypeReference> responseType, int expectedStatus) + throws IOException, InterruptedException { + return sendList("POST", path, body, responseType, expectedStatus); + } + + private T put(String path, Object body, Class responseType, int expectedStatus) + throws IOException, InterruptedException { + return send("PUT", path, body, responseType, expectedStatus); + } + + private T get(String path, Class responseType, int expectedStatus) + throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != expectedStatus) { + throw new MedMapClientException("GET " + path, + response.statusCode(), expectedStatus, response.body()); + } + return mapper.readValue(response.body(), responseType); + } + + private List getList(String path, TypeReference> responseType, int expectedStatus) + throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != expectedStatus) { + throw new MedMapClientException("GET " + path, + response.statusCode(), expectedStatus, response.body()); + } + return mapper.readValue(response.body(), responseType); + } + + private T send(String method, String path, Object body, Class responseType, int expectedStatus) + throws IOException, InterruptedException { + String json = mapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(json)) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != expectedStatus) { + throw new MedMapClientException(method + " " + path, + response.statusCode(), expectedStatus, response.body()); + } + return mapper.readValue(response.body(), responseType); + } + + private List sendList(String method, String path, Object body, TypeReference> responseType, int expectedStatus) + throws IOException, InterruptedException { + String json = mapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(json)) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != expectedStatus) { + throw new MedMapClientException(method + " " + path, + response.statusCode(), expectedStatus, response.body()); + } + return mapper.readValue(response.body(), responseType); + } + + // ── Exception ───────────────────────────────────────────────────────────── + + public static class MedMapClientException extends RuntimeException { + public final int statusCode; + public final String responseBody; + + MedMapClientException(String endpoint, int actual, int expected, String body) { + super(endpoint + " returned " + actual + " (expected " + expected + "): " + body); + this.statusCode = actual; + this.responseBody = body; + } + } +} diff --git a/src/main/java/com/voidsoftware/map02/config/ActiveAccountFilter.java b/src/main/java/com/voidsoftware/map02/config/ActiveAccountFilter.java new file mode 100644 index 0000000..a63bd56 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/ActiveAccountFilter.java @@ -0,0 +1,31 @@ +package com.voidsoftware.map02.config; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.repositories.AppUserRepository; +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.CookieClearingLogoutHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; + +/** Invalidate stale sessions on their next request, including sessions on other instances. */ +public class ActiveAccountFilter extends OncePerRequestFilter { + private final AppUserRepository users; + + public ActiveAccountFilter(AppUserRepository users) { this.users = users; } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof AppUser account + && !users.existsByIdAndEnabledTrue(account.getId())) { + new SecurityContextLogoutHandler().logout(request, response, authentication); + new CookieClearingLogoutHandler("JSESSIONID").logout(request, response, authentication); + } + chain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/config/CurrentUser.java b/src/main/java/com/voidsoftware/map02/config/CurrentUser.java new file mode 100644 index 0000000..71bc640 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/CurrentUser.java @@ -0,0 +1,10 @@ +package com.voidsoftware.map02.config; + +import java.lang.annotation.*; + +/** Marks a controller parameter to be resolved as the authenticated AppUser. */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CurrentUser { +} diff --git a/src/main/java/com/voidsoftware/map02/config/CurrentUserArgumentResolver.java b/src/main/java/com/voidsoftware/map02/config/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..15eda98 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/CurrentUserArgumentResolver.java @@ -0,0 +1,31 @@ +package com.voidsoftware.map02.config; + +import com.voidsoftware.map02.entities.AppUser; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(AppUser.class) + && parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof AppUser)) { + return null; + } + return (AppUser) auth.getPrincipal(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/config/OpenApiConfig.java b/src/main/java/com/voidsoftware/map02/config/OpenApiConfig.java new file mode 100644 index 0000000..2525c9d --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/OpenApiConfig.java @@ -0,0 +1,33 @@ +package com.voidsoftware.map02.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + private static final String COOKIE_SCHEME = "cookieAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("MedMap API") + .version("1.0.0") + .description("REST API for the MedMap medication scheduling platform.") + .contact(new Contact().name("VoidSoftware"))) + .addSecurityItem(new SecurityRequirement().addList(COOKIE_SCHEME)) + .components(new Components() + .addSecuritySchemes(COOKIE_SCHEME, new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name("JSESSIONID") + .description("Session cookie obtained after POST /api/auth/login"))); + } +} diff --git a/src/main/java/com/voidsoftware/map02/config/RoleBasedLoginSuccessHandler.java b/src/main/java/com/voidsoftware/map02/config/RoleBasedLoginSuccessHandler.java new file mode 100644 index 0000000..92cb0e8 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/RoleBasedLoginSuccessHandler.java @@ -0,0 +1,30 @@ +package com.voidsoftware.map02.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +import java.io.IOException; + +/** Always land on the appropriate board, rather than a stale saved request. */ +public class RoleBasedLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + public RoleBasedLoginSuccessHandler() { + setAlwaysUseDefaultTargetUrl(true); + } + + @Override + protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + return authentication.getAuthorities().stream().anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority())) + ? "/admin/dashboard" : "/dashboard"; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + new org.springframework.security.web.savedrequest.HttpSessionRequestCache().removeRequest(request, response); + super.onAuthenticationSuccess(request, response, authentication); + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/config/SecurityConfig.java b/src/main/java/com/voidsoftware/map02/config/SecurityConfig.java new file mode 100644 index 0000000..910d840 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/SecurityConfig.java @@ -0,0 +1,126 @@ +package com.voidsoftware.map02.config; + +import com.voidsoftware.map02.services.AppUserDetailsService; +import com.voidsoftware.map02.repositories.AppUserRepository; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity // enables @PreAuthorize / @Secured on methods +public class SecurityConfig { + + private final AppUserDetailsService userDetailsService; + private final AppUserRepository appUserRepository; + + public SecurityConfig(AppUserDetailsService userDetailsService, AppUserRepository appUserRepository) { + this.userDetailsService = userDetailsService; + this.appUserRepository = appUserRepository; + } + + // --- Beans --- + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** Not a @Bean — kept private to avoid triggering the UserDetailsService auto-config warning. */ + private DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + // --- HTTP security rules --- + + /** API chain — handles /api/** first. No CSRF, returns JSON errors. */ + @Bean + @Order(1) + public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") + .addFilterBefore(new ActiveAccountFilter(appUserRepository), AnonymousAuthenticationFilter.class) + .authenticationProvider(authenticationProvider()) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + ) + // Return 401 JSON instead of redirecting to the login page + .exceptionHandling(ex -> ex + .authenticationEntryPoint((req, res, e) -> { + res.setStatus(HttpStatus.UNAUTHORIZED.value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.getWriter().write("{\"error\":\"Unauthorized\"}"); + }) + .accessDeniedHandler((req, res, e) -> { + res.setStatus(HttpStatus.FORBIDDEN.value()); + res.setContentType(MediaType.APPLICATION_JSON_VALUE); + res.getWriter().write("{\"error\":\"Forbidden\"}"); + }) + ); + return http.build(); + } + + /** Web chain — handles everything else (Thymeleaf UI). */ + @Bean + @Order(2) + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .addFilterBefore(new ActiveAccountFilter(appUserRepository), AnonymousAuthenticationFilter.class) + .authenticationProvider(authenticationProvider()) + .authorizeHttpRequests(auth -> auth + + // ── Public ────────────────────────────────────────────────── + .requestMatchers("/login", "/signup").permitAll() + .requestMatchers("/css/**", "/images/**", "/error").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + + // ── Admin only ─────────────────────────────────────────────── + .requestMatchers("/admin", "/admin/**", "/companies/**").hasRole("ADMIN") + + // ── Professional + Admin ───────────────────────────────────── + .requestMatchers("/patients/**", "/professionals/**") + .hasAnyRole("PROFESSIONAL", "ADMIN") + + // ── Everything else needs a valid session ──────────────────── + .anyRequest().authenticated() + ) + .formLogin(form -> form + .loginPage("/login") // your custom Thymeleaf page + .usernameParameter("email") // matches the field name in the form + .successHandler(new RoleBasedLoginSuccessHandler()) + .failureUrl("/login?error") + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") + .permitAll() + ); + + return http.build(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/config/WebConfig.java b/src/main/java/com/voidsoftware/map02/config/WebConfig.java new file mode 100644 index 0000000..2dce8ed --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/config/WebConfig.java @@ -0,0 +1,16 @@ +package com.voidsoftware.map02.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new CurrentUserArgumentResolver()); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/AdminBoardController.java b/src/main/java/com/voidsoftware/map02/controllers/AdminBoardController.java new file mode 100644 index 0000000..593da01 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/AdminBoardController.java @@ -0,0 +1,29 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.services.AdminBoardService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import java.security.Principal; + +@Controller +@PreAuthorize("hasRole('ADMIN')") +public class AdminBoardController { + private final AdminBoardService adminBoardService; + + public AdminBoardController(AdminBoardService adminBoardService) { + this.adminBoardService = adminBoardService; + } + + @GetMapping({"/admin", "/admin/dashboard"}) + public String dashboard(Principal principal, @RequestParam(defaultValue = "users") String tab, + @RequestParam(defaultValue = "") String q, + @RequestParam(defaultValue = "0") int page, Model model) { + model.addAttribute("pageTitle", "Administração"); + model.addAttribute("adminName", principal.getName()); + model.addAttribute("board", adminBoardService.build(tab, q, page)); + return "admin-dashboard"; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/controllers/AdminDoseController.java b/src/main/java/com/voidsoftware/map02/controllers/AdminDoseController.java new file mode 100644 index 0000000..b61e8d9 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/AdminDoseController.java @@ -0,0 +1,65 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.services.DoseGenerationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Set; + +@Slf4j +@Controller +@PreAuthorize("hasRole('ADMIN')") +public class AdminDoseController { + private static final int MAX_DAYS_AHEAD = 90; + private final DoseGenerationService doseGenerationService; + + public AdminDoseController(DoseGenerationService doseGenerationService) { + this.doseGenerationService = doseGenerationService; + } + + @PostMapping("/admin/doses/generate") + public String generate(@RequestParam(defaultValue = "") String daysAhead, + @RequestParam(defaultValue = "false") boolean confirmed, + @RequestParam(defaultValue = "users") String tab, + @RequestParam(defaultValue = "") String q, + @RequestParam(defaultValue = "0") int page, + RedirectAttributes redirect) { + redirect.addAttribute("tab", Set.of("users", "companies", "professionals", "patients").contains(tab) ? tab : "users"); + redirect.addAttribute("q", q); + redirect.addAttribute("page", Math.max(0, page)); + int days; + try { + days = Integer.parseInt(daysAhead.strip()); + if (days < 0 || days > MAX_DAYS_AHEAD) throw new NumberFormatException(); + } catch (NumberFormatException e) { + redirect.addFlashAttribute("adminError", "Escolha um número inteiro entre 0 e 90 dias no futuro."); + return "redirect:/admin/dashboard"; + } + redirect.addFlashAttribute("doseDaysAhead", days); + if (!confirmed) { + redirect.addFlashAttribute("adminError", "Confirme a geração de doses para todos os pacientes."); + return "redirect:/admin/dashboard"; + } + + // Same server-calendar convention as the scheduled job; each prescription uses its patient's timezone. + LocalDate from = LocalDate.now(); + LocalDate to = from.plusDays(days); + try { + // Deliberately bypass the daily run marker: manual top-ups must work after the midnight job. + int created = doseGenerationService.generateDosesForAllPatients(from, to).size(); + DateTimeFormatter format = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + redirect.addFlashAttribute("adminSuccess", "Execução terminada: " + created + " novas doses criadas de " + + from.format(format) + " a " + to.format(format) + ". Consulte os registos para eventuais falhas por prescrição."); + } catch (RuntimeException e) { + log.error("Manual dose generation failed for range [{}, {}]", from, to, e); + redirect.addFlashAttribute("adminError", "Não foi possível concluir a geração de doses. Consulte os registos antes de repetir a operação."); + } + return "redirect:/admin/dashboard"; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/controllers/AdminPatientController.java b/src/main/java/com/voidsoftware/map02/controllers/AdminPatientController.java new file mode 100644 index 0000000..52402ee --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/AdminPatientController.java @@ -0,0 +1,50 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.services.SeedDataService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.Set; +import java.util.UUID; + +@Slf4j +@Controller +@RequestMapping("/admin/patients") +@PreAuthorize("hasRole('ADMIN')") +public class AdminPatientController { + private final SeedDataService seedDataService; + + public AdminPatientController(SeedDataService seedDataService) { + this.seedDataService = seedDataService; + } + + @PostMapping("/{id}/prescriptions/generate") + public String generatePrescriptions(@PathVariable UUID id, @RequestParam int count, + @RequestParam(defaultValue = "false") boolean confirmed, + @RequestParam(defaultValue = "patients") String tab, + @RequestParam(defaultValue = "") String q, + @RequestParam(defaultValue = "0") int page, + @AuthenticationPrincipal AppUser actor, + RedirectAttributes redirect) { + try { + if (!confirmed) throw new IllegalArgumentException("Confirme que pretende criar prescrições de teste."); + int created = seedDataService.generatePrescriptions(actor, id, count).size(); + redirect.addFlashAttribute("adminSuccess", created + " prescrições de teste criadas com os respetivos horários."); + } catch (IllegalArgumentException e) { + redirect.addFlashAttribute("adminError", e.getMessage()); + } catch (DataAccessException e) { + log.error("Failed to generate test prescriptions for patient {}", id, e); + redirect.addFlashAttribute("adminError", "Não foi possível guardar as prescrições. Nenhuma alteração deste lote foi guardada."); + } + redirect.addAttribute("tab", Set.of("patients", "professionals", "companies").contains(tab) ? tab : "patients"); + redirect.addAttribute("q", q); + redirect.addAttribute("page", Math.max(0, page)); + return "redirect:/admin/dashboard"; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/controllers/AdminUserController.java b/src/main/java/com/voidsoftware/map02/controllers/AdminUserController.java new file mode 100644 index 0000000..99ce620 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/AdminUserController.java @@ -0,0 +1,55 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.services.AdminUserManagementService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import java.security.Principal; +import java.util.UUID; + +@Controller +@RequestMapping("/admin/users") +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserController { + private final AdminUserManagementService service; + + public AdminUserController(AdminUserManagementService service) { this.service = service; } + + @PostMapping("/{id}/status") + public String status(@PathVariable UUID id, @RequestParam boolean enabled, + @RequestParam(defaultValue = "") String q, @RequestParam(defaultValue = "0") int page, + Principal principal, RedirectAttributes redirect) { + try { + service.setEnabled(id, enabled, principal.getName()); + redirect.addFlashAttribute("adminSuccess", enabled ? "Utilizador ativado." : "Utilizador desativado. O acesso foi bloqueado."); + } catch (IllegalArgumentException e) { + redirect.addFlashAttribute("adminError", e.getMessage()); + } + return back(q, page, redirect); + } + + @PostMapping("/{id}/delete") + public String delete(@PathVariable UUID id, @RequestParam(defaultValue = "false") boolean confirmed, + @RequestParam(defaultValue = "") String q, @RequestParam(defaultValue = "0") int page, + Principal principal, RedirectAttributes redirect) { + try { + if (!confirmed) throw new IllegalArgumentException("Confirme a eliminação da conta para continuar."); + service.delete(id, principal.getName()); + redirect.addFlashAttribute("adminSuccess", "Conta eliminada. Os perfis e dados clínicos foram preservados."); + } catch (IllegalArgumentException e) { + redirect.addFlashAttribute("adminError", e.getMessage()); + } catch (DataIntegrityViolationException e) { + redirect.addFlashAttribute("adminError", "Não foi possível eliminar a conta porque existem registos dependentes. Pode desativá-la."); + } + return back(q, page, redirect); + } + + private String back(String q, int page, RedirectAttributes redirect) { + redirect.addAttribute("tab", "users"); + redirect.addAttribute("q", q); + redirect.addAttribute("page", Math.max(0, page)); + return "redirect:/admin/dashboard"; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/controllers/AuthController.java b/src/main/java/com/voidsoftware/map02/controllers/AuthController.java new file mode 100644 index 0000000..ce4a380 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/AuthController.java @@ -0,0 +1,60 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.models.inout.SignupRequest; +import com.voidsoftware.map02.services.AppUserService; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class AuthController { + + private final AppUserService appUserService; + + public AuthController(AppUserService appUserService) { + this.appUserService = appUserService; + } + + // --- Login --- + + @GetMapping("/login") + public String loginPage(@RequestParam(required = false) String error, + @RequestParam(required = false) String logout, + @RequestParam(required = false) String registered, + Model model) { + model.addAttribute("pageTitle", "Login"); + if (error != null) model.addAttribute("loginError", "Email ou password incorretos."); + if (logout != null) model.addAttribute("loginInfo", "Sessão terminada com sucesso."); + if (registered != null) model.addAttribute("loginInfo", "Conta criada. Pode agora iniciar sessão."); + return "login"; + } + + // --- Signup --- + + @GetMapping("/signup") + public String signupPage(Model model) { + model.addAttribute("signupRequest", new SignupRequest()); + return "signup"; + } + + @PostMapping("/signup") + public String signup(@Valid @ModelAttribute SignupRequest signupRequest, + BindingResult result, + Model model) { + if (result.hasErrors()) { + return "signup"; + } + try { + appUserService.register(signupRequest); + return "redirect:/login?registered"; + } catch (IllegalArgumentException e) { + model.addAttribute("error", e.getMessage()); + return "signup"; + } + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/CompaniesController.java b/src/main/java/com/voidsoftware/map02/controllers/CompaniesController.java new file mode 100644 index 0000000..0fea23a --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/CompaniesController.java @@ -0,0 +1,48 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.dto.CompanyDTO; +import com.voidsoftware.map02.models.inout.CreateCompanyRequest; +import com.voidsoftware.map02.models.inout.ListOfCompaniesResponse; +import com.voidsoftware.map02.services.CompanyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Companies", description = "Manage healthcare organisations") +@RestController +@RequestMapping("/api/companies") +public class CompaniesController { + + private final CompanyService companyService; + + public CompaniesController(CompanyService companyService) { + this.companyService = companyService; + } + + @Operation(summary = "Create company", description = "Creates a new healthcare organisation. Requires ADMIN role.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Company created"), + @ApiResponse(responseCode = "403", description = "Insufficient permissions") + }) + @PostMapping + public ResponseEntity createCompany(@CurrentUser AppUser sessionUser, + @RequestBody CreateCompanyRequest request) { + + + CompanyDTO createdCompany = companyService.createCompany(sessionUser, request); + return ResponseEntity.status(HttpStatus.CREATED).body(createdCompany); + } + + @Operation(summary = "List all companies", description = "Returns every registered healthcare organisation.") + @ApiResponse(responseCode = "200", description = "List returned") + @GetMapping + public ResponseEntity listAll() { + return ResponseEntity.ok(companyService.getAllCompanies()); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/DashboardController.java b/src/main/java/com/voidsoftware/map02/controllers/DashboardController.java new file mode 100644 index 0000000..b27dcfb --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/DashboardController.java @@ -0,0 +1,40 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.services.DashboardService; +import com.voidsoftware.map02.services.PermissionsValidatorService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.util.Optional; + +@Controller +public class DashboardController { + + private final DashboardService dashboardService; + private final PermissionsValidatorService permissionsValidatorService; + + public DashboardController(DashboardService dashboardService, PermissionsValidatorService permissionsValidatorService) { + this.dashboardService = dashboardService; + this.permissionsValidatorService = permissionsValidatorService; + } + + @GetMapping("/dashboard") + public String dashboard(@CurrentUser AppUser sessionUser, Model model) { + if (sessionUser != null && sessionUser.getRole() == Role.ADMIN) { + return "redirect:/admin/dashboard"; + } + Optional professional = permissionsValidatorService.validateProfessional(sessionUser); + if (professional.isEmpty()) { + return "redirect:/login"; + } + + model.addAttribute("pageTitle", "Dashboard"); + model.addAttribute("dashboard", dashboardService.buildDashboard(professional.get())); + return "dashboard"; + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/DoseController.java b/src/main/java/com/voidsoftware/map02/controllers/DoseController.java new file mode 100644 index 0000000..0089449 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/DoseController.java @@ -0,0 +1,91 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.models.inout.GenerateDosesRequest; +import com.voidsoftware.map02.models.inout.RecordDoseEventRequest; +import com.voidsoftware.map02.services.DoseGenerationService; +import com.voidsoftware.map02.services.DoseTrackingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Tag(name = "Doses", description = "Generate scheduled doses and record what happened to them") +@RestController +@RequestMapping("/api/doses") +public class DoseController { + + private final DoseGenerationService doseGenerationService; + private final DoseTrackingService doseTrackingService; + + public DoseController(DoseGenerationService doseGenerationService, DoseTrackingService doseTrackingService) { + this.doseGenerationService = doseGenerationService; + this.doseTrackingService = doseTrackingService; + } + + @Operation(summary = "Generate doses", description = "Expands a prescription's schedule(s) into individual pending doses for the given date window. Safe to re-run — existing doses are not duplicated.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Doses generated (may be empty for PRN prescriptions)"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Prescription not found"), + @ApiResponse(responseCode = "409", description = "Prescription is not ACTIVE") + }) + @PostMapping("/generate") + public ResponseEntity> generateDoses(@Valid @RequestBody GenerateDosesRequest request) { + List doses = doseGenerationService.generateDoses( + request.getPrescriptionId(), request.getFromDate(), request.getToDate()); + return ResponseEntity.ok(doses); + } + + @Operation(summary = "Generate doses", description = "Expands a prescription's schedule(s) into individual pending doses for the given date window. Safe to re-run — existing doses are not duplicated.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Doses generated (may be empty for PRN prescriptions)"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Prescription not found"), + @ApiResponse(responseCode = "409", description = "Prescription is not ACTIVE") + }) + @PostMapping("/generate/all") + public ResponseEntity> generateAllDoses(@Valid @RequestBody GenerateDosesRequest request) { + List doses = doseGenerationService.generateDosesForAllPatients( + request.getFromDate(), request.getToDate()); + return ResponseEntity.ok(doses); + } + + + @Operation(summary = "Mark dose as taken") + @PostMapping("/{doseId}/take") + public ResponseEntity takeDose(@CurrentUser AppUser sessionUser, + @PathVariable UUID doseId, + @RequestBody(required = false) RecordDoseEventRequest request) { + Instant occurredAt = request != null ? request.getOccurredAt() : null; + return ResponseEntity.ok(doseTrackingService.recordTaken(doseId, sessionUser, occurredAt)); + } + + @Operation(summary = "Mark dose as skipped") + @PostMapping("/{doseId}/skip") + public ResponseEntity skipDose(@CurrentUser AppUser sessionUser, + @PathVariable UUID doseId, + @RequestBody(required = false) RecordDoseEventRequest request) { + Instant occurredAt = request != null ? request.getOccurredAt() : null; + String reason = request != null ? request.getReason() : null; + return ResponseEntity.ok(doseTrackingService.recordSkipped(doseId, sessionUser, occurredAt, reason)); + } + + @Operation(summary = "Snooze a dose", description = "Pushes the dose's scheduled time back by snoozeMinutes and records a SNOOZED event.") + @PostMapping("/{doseId}/snooze") + public ResponseEntity snoozeDose(@CurrentUser AppUser sessionUser, + @PathVariable UUID doseId, + @Valid @RequestBody RecordDoseEventRequest request) { + int minutes = request.getSnoozeMinutes() != null ? request.getSnoozeMinutes() : 15; + return ResponseEntity.ok(doseTrackingService.recordSnoozed(doseId, sessionUser, request.getOccurredAt(), minutes)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/MedicationsController.java b/src/main/java/com/voidsoftware/map02/controllers/MedicationsController.java new file mode 100644 index 0000000..dbb8def --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/MedicationsController.java @@ -0,0 +1,50 @@ +package com.voidsoftware.map02.controllers; + + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.dto.MedicationDTO; +import com.voidsoftware.map02.models.inout.CreateMedicationRequest; +import com.voidsoftware.map02.models.inout.ListOfMedicationsResponse; +import com.voidsoftware.map02.services.MedicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Medications", description = "Manage medications") +@RestController +@RequestMapping("/api/medication") +public class MedicationsController { + + private final MedicationService medicationService; + + public MedicationsController(MedicationService medicationService) { + this.medicationService = medicationService; + } + + @Operation(summary = "Create medication", description = "Create a new medication ") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Patient created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Professional not found") + }) + @PostMapping("/create") + public ResponseEntity createMedication(@CurrentUser AppUser sessionUser, + @Valid @RequestBody CreateMedicationRequest request) throws IllegalAccessException { + MedicationDTO createdMedication = medicationService.createMedication(sessionUser, request); + return ResponseEntity.status(HttpStatus.CREATED).body(createdMedication); + } + + + @Operation(summary = "List all medications", description = "Returns every medication on the database") + @ApiResponse(responseCode = "200", description = "List returned") + @GetMapping("/list") + public ResponseEntity listAll(@CurrentUser AppUser sessionUser) { + return ResponseEntity.ok(medicationService.getAllMedications(sessionUser)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/PatientDetailController.java b/src/main/java/com/voidsoftware/map02/controllers/PatientDetailController.java new file mode 100644 index 0000000..cf5eb12 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/PatientDetailController.java @@ -0,0 +1,56 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.services.DataConversionService; +import com.voidsoftware.map02.services.PatientDetailService; +import com.voidsoftware.map02.services.PermissionsValidatorService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +@Controller +public class PatientDetailController { + + private final PatientDetailService patientDetailService; + private final PermissionsValidatorService permissionsValidatorService; + private final DataConversionService dataConversionService; + + public PatientDetailController(PatientDetailService patientDetailService, + PermissionsValidatorService permissionsValidatorService, + DataConversionService dataConversionService) { + this.patientDetailService = patientDetailService; + this.permissionsValidatorService = permissionsValidatorService; + this.dataConversionService = dataConversionService; + } + + @GetMapping("/patients/{id}") + public String patientDetail(@CurrentUser AppUser sessionUser, @PathVariable UUID id, + @RequestParam(defaultValue = "weekly") String view, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + Model model) { + Optional professional = permissionsValidatorService.validateProfessional(sessionUser); + if (professional.isEmpty()) { + return "redirect:/login"; + } + + return patientDetailService.build(id, professional.get(), view, date) + .map(patient -> { + model.addAttribute("pageTitle", patient.getName()); + model.addAttribute("patient", patient); + model.addAttribute("professionalName", professional.get().getName()); + model.addAttribute("professionalPhoto", professional.get().getResourceId() == null ? null + : dataConversionService.getImageResource(professional.get().getResourceId()).orElse(null)); + return "patient-detail"; + }) + .orElse("redirect:/dashboard"); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java b/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java new file mode 100644 index 0000000..3b711db --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/PatientsController.java @@ -0,0 +1,93 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.dto.PatientDTO; +import com.voidsoftware.map02.models.inout.*; +import com.voidsoftware.map02.services.PatientsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@Tag(name = "Patients", description = "Manage patients and their prescriptions") +@RestController +@RequestMapping("/api/patients") +public class PatientsController { + + private final PatientsService patientsService; + + + public PatientsController(PatientsService patientsService) { + this.patientsService = patientsService; + + + } + + @Operation(summary = "Create patient", description = "Registers a new patient under the given professional.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Patient created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Professional not found") + }) + @PostMapping("/create") + public ResponseEntity createPatient(@CurrentUser AppUser sessionUser, + @Valid @RequestBody CreatePatientRequest request) { + PatientDTO createdPatient = patientsService.createPatient(sessionUser, request); + return ResponseEntity.status(HttpStatus.CREATED).body(createdPatient); + } + + + @Operation(summary = "Update photo patient", description = "") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Patient created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Professional not found") + }) + @PostMapping("/update/photo") + public ResponseEntity changePatientPhoto(@CurrentUser AppUser sessionUser, + @Valid @RequestBody ChangeMediaEntityImageRequest request) { + if (patientsService.updatePatientPhoto(sessionUser, request)) { + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + return ResponseEntity.badRequest().build(); + } + + @Operation(summary = "Get patient", description = "Returns a single patient by ID.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Patient found"), + @ApiResponse(responseCode = "404", description = "Patient not found") + }) + @GetMapping("/{id}") + public ResponseEntity getPatient(@CurrentUser AppUser sessionUser, + @PathVariable UUID id) { + return ResponseEntity.ok(patientsService.getPatientById(sessionUser, id)); + } + + @Operation(summary = "List patients by professional", description = "Returns all patients assigned to the given professional.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List returned"), + @ApiResponse(responseCode = "404", description = "Professional not found") + }) + @PostMapping("/list") + public ResponseEntity listByProfessional(@CurrentUser AppUser sessionUser, + @Valid @RequestBody ListPatientsRequest request) { + return ResponseEntity.ok( + new ListOfPatientsResponse(patientsService.getPatientsListByProfessional(sessionUser, request))); + } + + + @Operation(summary = "get patient medication periods", description = "Returns every medication on the database") + @ApiResponse(responseCode = "200", description = "List returned") + @GetMapping + public ResponseEntity getPatientMedicationDayPeriods(@CurrentUser AppUser sessionUser) { + return ResponseEntity.ok(this.patientsService.getPatientMedicationDayPeriods(sessionUser)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java b/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java new file mode 100644 index 0000000..aa177d2 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/PrescriptionController.java @@ -0,0 +1,74 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.models.inout.ChangePrescriptionScheduleRequest; +import com.voidsoftware.map02.models.inout.CreatePrescriptionRequest; +import com.voidsoftware.map02.models.inout.ListPrescriptionsRequest; +import com.voidsoftware.map02.models.inout.PrescriptionWithSchedulesResponse; +import com.voidsoftware.map02.services.PrescriptionsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Prescriptions", description = "Manage prescriptions") +@RestController +@RequestMapping("/api/prescriptions") +public class PrescriptionController { + private final PrescriptionsService prescriptionsService; + + public PrescriptionController(PrescriptionsService prescriptionsService) { + this.prescriptionsService = prescriptionsService; + } + + @Operation(summary = "Create prescription", description = "Creates a prescription and its initial schedule for the given patient.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Prescription created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Patient, medication or professional not found") + }) + @PostMapping("/create") + public ResponseEntity createPrescription(@CurrentUser AppUser sessionUser, + @Valid @RequestBody CreatePrescriptionRequest request) { + Prescription prescription = prescriptionsService.createPrescription(sessionUser, request); + return ResponseEntity.status(HttpStatus.CREATED).body(prescription); + } + + @Operation(summary = "Change prescription schedule", description = "Closes the active schedule version and opens a new one from the given effective date.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "New schedule version created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Prescription not found"), + @ApiResponse(responseCode = "409", description = "Prescription is not ACTIVE") + }) + @PutMapping("/schedule") + public ResponseEntity changePrescriptionSchedule( + @CurrentUser AppUser sessionUser, + @Valid @RequestBody ChangePrescriptionScheduleRequest request) { + return ResponseEntity.ok(prescriptionsService.changePatientPrescription(sessionUser, request)); + } + + @Operation( + summary = "List patient prescriptions", + description = "Returns prescriptions for a patient. Use activeOnly=true to filter by status=ACTIVE. " + + "scheduleFilter=ACTIVE_ONLY returns only the open schedule version per prescription; " + + "scheduleFilter=ALL returns the full version history." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List returned"), + @ApiResponse(responseCode = "404", description = "Patient not found") + }) + @PostMapping("/list") + public ResponseEntity> listPrescriptions( + @Valid @RequestBody ListPrescriptionsRequest request) { + return ResponseEntity.ok(prescriptionsService.listPrescriptions(request)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/ProfessionalsController.java b/src/main/java/com/voidsoftware/map02/controllers/ProfessionalsController.java new file mode 100644 index 0000000..5173d26 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/ProfessionalsController.java @@ -0,0 +1,56 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.dto.ProfessionalDTO; +import com.voidsoftware.map02.models.inout.CreateProfessionalRequest; +import com.voidsoftware.map02.models.inout.ListOfProfessionalsResponse; +import com.voidsoftware.map02.models.inout.ListProfessionalsRequest; +import com.voidsoftware.map02.services.ProfessionalsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Professionals", description = "Manage healthcare professionals") +@RestController +@RequestMapping("/api/professionals") +public class ProfessionalsController { + + private final ProfessionalsService professionalsService; + + public ProfessionalsController(ProfessionalsService professionalsService) { + this.professionalsService = professionalsService; + } + + @Operation(summary = "Create professional", description = "Registers a new healthcare professional under an existing company.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Professional created"), + @ApiResponse(responseCode = "400", description = "Validation error"), + @ApiResponse(responseCode = "404", description = "Company not found") + }) + @PostMapping + public ResponseEntity createProfessional(@CurrentUser AppUser sessionUser, + @Valid @RequestBody CreateProfessionalRequest request) { + ProfessionalDTO createdProfessional = professionalsService.createProfessional(sessionUser, request); + return ResponseEntity.status(HttpStatus.CREATED).body(createdProfessional); + } + + @Operation(summary = "List professionals by company", description = "Returns all professionals belonging to the given company.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List returned"), + @ApiResponse(responseCode = "404", description = "Company not found") + }) + @PostMapping("/list") + public ResponseEntity listByCompany(@Valid @RequestBody ListProfessionalsRequest request) { + return ResponseEntity.ok(professionalsService.listByCompany(request)); + } + +} diff --git a/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java b/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java new file mode 100644 index 0000000..d76d81e --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/controllers/RestAuthController.java @@ -0,0 +1,90 @@ +package com.voidsoftware.map02.controllers; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.inout.LoginResponse; +import com.voidsoftware.map02.models.inout.LoginRequest; +import com.voidsoftware.map02.models.inout.SignupRequest; +import com.voidsoftware.map02.services.AppUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Authentication", description = "Register and log in to obtain a session cookie") +@RestController +@RequestMapping("/api/auth") +public class RestAuthController { + + private final AppUserService appUserService; + private final AuthenticationManager authenticationManager; + + public RestAuthController(AppUserService appUserService, + AuthenticationManager authenticationManager) { + this.appUserService = appUserService; + this.authenticationManager = authenticationManager; + } + + @Operation(summary = "Log in", description = "Authenticates with email and password and returns a session cookie.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Authenticated — JSESSIONID cookie set"), + @ApiResponse(responseCode = "401", description = "Invalid credentials") + }) + @SecurityRequirements + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + ); + + // Persist the authentication in the session so subsequent requests stay authenticated + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + HttpSession session = httpRequest.getSession(true); + session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + AppUser user = (AppUser) auth.getPrincipal(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Id", user.getId().toString()); + headers.set("X-User-Role", user.getRole().name()); + // add any further custom headers here + + return ResponseEntity.ok().headers(headers).body(LoginResponse.from(user)); + } + + @Operation(summary = "Sign up", description = "Creates a new account and returns the new user's session.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Account created"), + @ApiResponse(responseCode = "409", description = "Email already registered") + }) + @SecurityRequirements + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + AppUser created = appUserService.register(request); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Id", created.getId().toString()); + headers.set("X-User-Role", created.getRole().name()); + // add any further custom headers here + + return ResponseEntity.status(201).headers(headers).body(LoginResponse.from(created)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/AppUser.java b/src/main/java/com/voidsoftware/map02/entities/AppUser.java new file mode 100644 index 0000000..5587dae --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/AppUser.java @@ -0,0 +1,101 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.Role; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "app_users", indexes = { + // Administrator locking filters by role and acquires rows in ID order. + @Index(name = "idx_app_users_role_id", columnList = "role, id") +}) +@Data +@NoArgsConstructor +public class AppUser implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Login identifier; must be unique across all accounts. + */ + @Column(nullable = false, unique = true) + private String email; + + /** + * BCrypt-hashed password; never stored in plain text. + */ + @Column(nullable = false) + private String password; + + /** + * Access role that controls which endpoints and resources this account may use. + */ + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + /** + * Soft-disable flag; false prevents login without deleting the account. + */ + @Column(nullable = false) + private boolean enabled = true; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "entityId", columnDefinition = "uuid") + private UUID entityId; + + + @PrePersist + public void prePersist() { + this.createdAt = Instant.now(); + } + + // --- UserDetails --- + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + } + + /** + * Spring Security uses this as the login identifier. + */ + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return enabled; + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/Company.java b/src/main/java/com/voidsoftware/map02/entities/Company.java new file mode 100644 index 0000000..fd8ee52 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/Company.java @@ -0,0 +1,45 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.MediaEntity; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "company", indexes = { + // Directory pagination order; substring filtering needs a trigram index separately. + @Index(name = "idx_company_name_id", columnList = "name, id") +}) +@Data +@NoArgsConstructor +public class Company implements MediaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Legal or trading name of the healthcare organisation. + */ + @Column(nullable = false) + private String name; + + /** + * Tax identification number (e.g. PT123456789). + */ + @Column(name = "vat_number") + private String vatNumber; + + /** + * Primary contact e-mail for the company, used for billing and system notifications. + */ + @Column(name = "contact_email") + private String contactEmail; + + @Column(columnDefinition = "uuid") + private UUID resourceId; +} + diff --git a/src/main/java/com/voidsoftware/map02/entities/CompanyProfessionals.java b/src/main/java/com/voidsoftware/map02/entities/CompanyProfessionals.java new file mode 100644 index 0000000..38fc33c --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/CompanyProfessionals.java @@ -0,0 +1,4 @@ +package com.voidsoftware.map02.entities; + +public class CompanyProfessionals { +} diff --git a/src/main/java/com/voidsoftware/map02/entities/DoseEvent.java b/src/main/java/com/voidsoftware/map02/entities/DoseEvent.java new file mode 100644 index 0000000..0fb96d2 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/DoseEvent.java @@ -0,0 +1,55 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "dose_event") +@Data +@NoArgsConstructor +public class DoseEvent { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Dose this event is an audit record for. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "dose_id", nullable = false) + private MedicationDose dose; + + /** + * What happened: TAKEN, SKIPPED, SNOOZED, RESCHEDULED, CANCELLED. + */ + @Column(name = "event_type", nullable = false) + private String eventType; + + /** + * UTC instant the event was recorded by the patient or system. + */ + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + /** + * UUID of the patient or system process that triggered the event. + */ + @Column(nullable = false) + private String actor; + + /** + * Optional structured context (e.g. snooze duration, skip reason). Stored as JSONB. + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map metadata; +} diff --git a/src/main/java/com/voidsoftware/map02/entities/DoseGenerationRun.java b/src/main/java/com/voidsoftware/map02/entities/DoseGenerationRun.java new file mode 100644 index 0000000..c73ee3e --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/DoseGenerationRun.java @@ -0,0 +1,70 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +/** + * Tracks whether the daily dose-generation sweep ({@code generateDosesForAllPatients}) has already + * run for a given calendar date, so the scheduled job stays idempotent even if it is triggered more + * than once (e.g. app restart, multiple instances). + */ +@Entity +@Table( + name = "dose_generation_run", + uniqueConstraints = { + @UniqueConstraint(name = "uq_dose_generation_run_date", columnNames = {"run_date"}) + } +) +@Data +@NoArgsConstructor +public class DoseGenerationRun { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Calendar date this sweep was generating doses for; unique so only one run can own a given date. + */ + @Column(name = "run_date", nullable = false) + private LocalDate runDate; + + /** + * RUNNING, COMPLETED or FAILED. + */ + @Column(name = "status", nullable = false) + private String status; + + @Column(name = "doses_created") + private Integer dosesCreated; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/MediaResource.java b/src/main/java/com/voidsoftware/map02/entities/MediaResource.java new file mode 100644 index 0000000..fa846ba --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/MediaResource.java @@ -0,0 +1,40 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "media_resource") +@Data +@NoArgsConstructor +public class MediaResource { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * SHA-256 hex digest of the file content; used for deduplication and integrity checks. + */ + @Column(nullable = false, unique = true, length = 64) + private String hash; + + /** + * Relative or absolute path to the stored file on the filesystem or object storage. + */ + @Column(name = "file_path", nullable = false) + private String filePath; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/Medication.java b/src/main/java/com/voidsoftware/map02/entities/Medication.java new file mode 100644 index 0000000..865cc08 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/Medication.java @@ -0,0 +1,62 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.DosageForm; +import com.voidsoftware.map02.models.MediaEntity; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "medication") +@Data +@NoArgsConstructor +public class Medication implements MediaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Brand or generic name of the product (e.g. "Xanax"). + */ + @Column(nullable = false) + private String name; + + /** + * INN / generic name of the pharmacologically active molecule (e.g. "Alprazolam"). + */ + @Column(name = "active_ingredient") + private String activeIngredient; + + /** + * Physical form of the product — determines how inventory units are counted. + */ + @Enumerated(EnumType.STRING) + @Column(name = "dosage_form") + private DosageForm dosageForm; + + /** + * Concentration per unit (e.g. "0.5mg", "100U/mL"). Different strengths are separate rows. + */ + @Column + private String strength; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + + @Column(columnDefinition = "uuid") + private UUID companyId; + + @Column(columnDefinition = "uuid") + private UUID creationUser; + + @Column(columnDefinition = "uuid") + private UUID resourceId; + @Column(columnDefinition = "uuid") + private UUID parentId; +} diff --git a/src/main/java/com/voidsoftware/map02/entities/MedicationAlert.java b/src/main/java/com/voidsoftware/map02/entities/MedicationAlert.java new file mode 100644 index 0000000..519ef6d --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/MedicationAlert.java @@ -0,0 +1,98 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "medication_alert", + uniqueConstraints = { + @UniqueConstraint(name = "uq_alert_dose_type", columnNames = {"medication_dose_id", "alert_type"}) + }, + indexes = { + @Index(name = "idx_alert_patient_type_status", columnList = "patient_id, alert_type, status"), + // Join from a professional's patients and retrieve their recent alerts. + @Index(name = "idx_alert_patient_scheduled", columnList = "patient_id, scheduled_at DESC") + } +) +@Data +@NoArgsConstructor +public class MedicationAlert { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Patient who should receive this alert. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + /** + * Dose that triggered the alert; null for stock-level alerts not tied to a specific dose. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "medication_dose_id") + private MedicationDose medicationDose; + + /** + * Category of alert: MISSED_DOSE, LOW_STOCK, UPCOMING_DOSE, REFILL_REQUIRED. + */ + @Column(name = "alert_type", nullable = false) + private String alertType; + + /** + * UTC instant the alert is supposed to be delivered. + */ + @Column(name = "scheduled_at", nullable = false) + private Instant scheduledAt; + + /** + * Delivery lifecycle: PENDING, SENT, ACKNOWLEDGED, FAILED. + */ + @Column(nullable = false) + private String status = "PENDING"; + + /** + * UTC instant the notification was successfully dispatched to the delivery channel. + */ + @Column(name = "sent_at") + private Instant sentAt; + + /** + * UTC instant the patient explicitly dismissed or acknowledged the alert. + */ + @Column(name = "acknowledged_at") + private Instant acknowledgedAt; + + /** + * Number of send attempts made; used to enforce a retry cap before marking FAILED. + */ + @Column(name = "delivery_attempts", nullable = false) + private Integer deliveryAttempts = 0; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/MedicationDose.java b/src/main/java/com/voidsoftware/map02/entities/MedicationDose.java new file mode 100644 index 0000000..8910c11 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/MedicationDose.java @@ -0,0 +1,112 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.DoseTakenState; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "medication_dose", + uniqueConstraints = { + @UniqueConstraint(name = "uq_dose_no_dup", columnNames = {"prescription_id", "scheduled_at"}) + }, + indexes = { + @Index(name = "idx_medication_dose_pending", columnList = "scheduled_at"), + @Index(name = "idx_medication_dose_servertime", columnList = "scheduled_at_server_time"), + // uq_dose_no_dup already covers prescription-only and prescription/UTC-window queries. + @Index(name = "idx_dose_status_scheduled", columnList = "status, scheduled_at"), + @Index(name = "idx_dose_status_servertime", columnList = "status, scheduled_at_server_time"), + @Index(name = "idx_dose_prescription_status_scheduled", columnList = "prescription_id, status, scheduled_at"), + // Time precedes status so patient cutoff counts WITHOUT a status predicate also benefit. + @Index(name = "idx_dose_prescription_servertime_status", columnList = "prescription_id, scheduled_at_server_time, status"), + @Index(name = "idx_dose_status_taken", columnList = "status, taken_at DESC") + } +) +@Data +@NoArgsConstructor +public class MedicationDose { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Prescription this individual dose belongs to. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "prescription_id", nullable = false) + private Prescription prescription; + + /** + * The schedule version that produced this dose row; preserved even when the schedule is later updated. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "schedule_version_id") + private PrescriptionSchedule scheduleVersion; + + /** + * UTC instant when this dose is due; derived from schedule times converted using the patient's timezone. + */ + @Column(name = "scheduled_at", nullable = false) + private Instant scheduledAt; + + /** + * Same instant as {@link #scheduledAt}, expressed as a wall-clock date/time in the server's local timezone. + */ + @Column(name = "scheduled_at_server_time", nullable = false) + private java.time.LocalDateTime scheduledAtServerTime; + + + + /* + @Column(name = "dose_amount", precision = 19, scale = 4) + private BigDecimal doseAmount; + + + @Column(name = "dose_unit") + private String doseUnit; +*/ + /** + * Current state: PENDING, TAKEN, SKIPPED, SNOOZED. Updated when a dose_event is recorded. + */ + + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private DoseTakenState status; + + /** + * UTC instant the patient confirmed taking the dose; null until status = TAKEN. + */ + @Column(name = "taken_at") + private Instant takenAt; + + /** + * Groups all doses created in the same generation run; used to roll back a partial batch. + */ + @Column(name = "generation_batch_id", columnDefinition = "uuid") + private UUID generationBatchId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/MedicationInventory.java b/src/main/java/com/voidsoftware/map02/entities/MedicationInventory.java new file mode 100644 index 0000000..5cecf19 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/MedicationInventory.java @@ -0,0 +1,56 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "medication_inventory", indexes = { + @Index(name = "idx_inventory_prescription", columnList = "prescription_id") +}) +@Data +@NoArgsConstructor +public class MedicationInventory { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Prescription whose physical stock this record tracks; one inventory row per prescription. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "prescription_id", nullable = false) + private Prescription prescription; + + /** + * Current number of units (tablets, mL, etc.) still available to the patient. + */ + @Column(name = "quantity_remaining", precision = 19, scale = 4) + private BigDecimal quantityRemaining; + + /** + * When quantityRemaining falls to or below this value a LOW_STOCK alert is raised. + */ + @Column(name = "refill_threshold", precision = 19, scale = 4) + private BigDecimal refillThreshold; + + /** + * UTC instant of the most recent dispensing event; used to compute days-supply remaining. + */ + @Column(name = "last_refilled_at") + private Instant lastRefilledAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/NotificationPreference.java b/src/main/java/com/voidsoftware/map02/entities/NotificationPreference.java new file mode 100644 index 0000000..29b7434 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/NotificationPreference.java @@ -0,0 +1,50 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "notification_preference") +@Data +@NoArgsConstructor +public class NotificationPreference { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Patient whose notification settings this row describes. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + /** + * Delivery channel: PUSH, SMS, EMAIL. + */ + @Column(nullable = false) + private String channel; + + /** + * Channel-specific address: device token, phone number, or e-mail address. + */ + @Column(nullable = false) + private String destination; + + /** + * Whether this channel is currently active for the patient. + */ + @Column(nullable = false) + private Boolean enabled = true; + + /** + * Lower value = higher priority; alerts are attempted in ascending priority order. + */ + @Column(nullable = false) + private Integer priority = 0; +} diff --git a/src/main/java/com/voidsoftware/map02/entities/Patient.java b/src/main/java/com/voidsoftware/map02/entities/Patient.java new file mode 100644 index 0000000..fac2e9d --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/Patient.java @@ -0,0 +1,136 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.Gender; +import com.voidsoftware.map02.models.MediaEntity; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "patient", indexes = { + @Index(name = "idx_patient_email", columnList = "email", unique = true), + @Index(name = "idx_patient_professional_id", columnList = "professional_id"), + @Index(name = "idx_patient_user_id", columnList = "user_id"), + // Directory pagination order, not case-insensitive substring filtering. + @Index(name = "idx_patient_name_id", columnList = "name, id") +}) +@Data +@NoArgsConstructor +public class Patient implements MediaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Full name of the patient. + */ + @Column(nullable = false) + private String name; + + /** + * Date of birth, used to calculate age and check paediatric dosing rules. + */ + @Column(name = "date_of_birth") + private LocalDate dateOfBirth; + + /** + * IANA timezone (e.g. Europe/Lisbon) used to convert scheduled UTC times to local display times. + */ + @Column(nullable = false) + private String timezone; + + /** + * Soft-delete flag; inactive patients are excluded from dose generation and alerts. + */ + @Column(nullable = false) + private Boolean active = true; + + /** + * Contact e-mail, also used as login identifier if the patient has an app account. + */ + // Uniqueness is already declared by idx_patient_email on the table. + @Column + private String email; + + /** + * Mobile number for SMS notification delivery. + */ + @Column(name = "phone_number") + private String phoneNumber; + + /** + * MALE, FEMALE, OTHER + */ + @Enumerated(EnumType.STRING) + @Column(name = "gender") + private Gender gender; + + /** + * Residential address, used for home-delivery or home-visit workflows. + */ + @Column + private String address; + + /** + * ABO/Rh blood type (e.g. A+), relevant for clinical decision support. + */ + @Column(name = "blood_type", length = 5) + private String bloodType; + + /** + * Name of the person to contact in a medical emergency. + */ + @Column(name = "emergency_contact_name") + private String emergencyContactName; + + /** + * Phone number of the emergency contact. + */ + @Column(name = "emergency_contact_phone") + private String emergencyContactPhone; + + /** + * Free-text clinical notes visible only to the assigned professional. + */ + @Column(columnDefinition = "text") + private String notes; + + /** + * Professional responsible for this patient's care. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "professional_id") + private Professional professional; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + + @Column(name = "user_id", columnDefinition = "uuid") + private UUID userId; + + + @Column(columnDefinition = "uuid") + private UUID resourceId; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/PatientPeriods.java b/src/main/java/com/voidsoftware/map02/entities/PatientPeriods.java new file mode 100644 index 0000000..f71d5bb --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/PatientPeriods.java @@ -0,0 +1,37 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.DayPeriodTypes; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalTime; +import java.util.UUID; + +@Entity +@Table(name = "patient_periods", indexes = { + @Index(name = "idx_patient_periods_patient", columnList = "patient_id") +}) +@Data +@NoArgsConstructor +public class PatientPeriods { + + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + @Column(name = "patient_id", columnDefinition = "uuid") + private UUID patientId; + + + @Enumerated(EnumType.STRING) + @Column(name = "period_type") + private DayPeriodTypes periodType; + + + @Column(name = "time_of_day", nullable = false) + private LocalTime timeOfDay; + +} diff --git a/src/main/java/com/voidsoftware/map02/entities/Prescription.java b/src/main/java/com/voidsoftware/map02/entities/Prescription.java new file mode 100644 index 0000000..9757a88 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/Prescription.java @@ -0,0 +1,94 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "prescription", indexes = { + // The leading patient_id also supports patient-only queries and dose-history joins. + @Index(name = "idx_prescription_patient_status", columnList = "patient_id, status"), + @Index(name = "idx_prescription_status", columnList = "status") +}) +@Data +@NoArgsConstructor +public class Prescription { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Patient this prescription was issued for. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "patient_id", nullable = false) + private Patient patient; + + /** + * Specific medication product (including strength) being prescribed. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "medication_id", nullable = false) + private Medication medication; + + /** + * Professional who issued this prescription. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "prescriber_id", nullable = false) + private Professional professional; + + /** + * First day the medication should be taken (inclusive, local calendar date). + */ + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + /** + * Last day the medication should be taken; null means open-ended. + */ + @Column(name = "end_date") + private LocalDate endDate; + + /** + * Lifecycle state: ACTIVE, SUSPENDED, COMPLETED, CANCELLED. + */ + @Column(nullable = false) + private String status = "ACTIVE"; + + /** + * Pro re nata — if true, no doses are pre-generated; patient logs intake on demand. + */ + @Column(nullable = false) + private Boolean prn = false; + + /** + * Free-text instructions shown to the patient (e.g. "Take with food"). + */ + @Column(columnDefinition = "text") + private String instructions; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + public void prePersist() { + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/PrescriptionSchedule.java b/src/main/java/com/voidsoftware/map02/entities/PrescriptionSchedule.java new file mode 100644 index 0000000..8d875fd --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/PrescriptionSchedule.java @@ -0,0 +1,102 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.FrequencyType; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "prescription_schedule", indexes = { + // Version history, MAX(version), and ordered active-version lookup (effective_to is residual). + @Index(name = "idx_schedule_prescription_version", columnList = "prescription_id, version"), + // Equality on prescription, then the effective_from range; effective_to is residual. + @Index(name = "idx_schedule_prescription_effective", columnList = "prescription_id, effective_from, effective_to") +}) +@Data +@NoArgsConstructor +public class PrescriptionSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Prescription this schedule version belongs to. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "prescription_id", nullable = false) + private Prescription prescription; + + /** + * Monotonically increasing counter; v1 is the original schedule, v2+ are mid-prescription changes. + */ + @Column(nullable = false) + private Integer version; + + /** + * Determines how dose times are interpreted (fixed clock times, interval, daily shortcuts, etc.). + */ + @Enumerated(EnumType.STRING) + @Column(name = "frequency_type", nullable = false) + private FrequencyType frequencyType; + + /** + * Numeric gap between doses; used only when frequencyType = INTERVAL (e.g. every 8 hours). + */ + @Column(name = "interval_value") + private Integer intervalValue; + + /** + * Unit for intervalValue: "hours" or "days". Used only when frequencyType = INTERVAL. + */ + @Column(name = "interval_unit") + private String intervalUnit; + + /** + * Local clock times at which a dose fires (e.g. ["08:00", "22:00"]). Stored in a child table. + */ + @ElementCollection + @CollectionTable(name = "prescription_schedule_times", joinColumns = @JoinColumn(name = "schedule_id"), + indexes = @Index(name = "idx_schedule_times_schedule", columnList = "schedule_id")) + @Column(name = "time_of_day") + private List timesOfDay = new ArrayList<>(); + + /** + * Amount of medication per single dose; copied onto every generated medication_dose row. + */ + @Column(name = "dose_amount", precision = 19, scale = 4) + private BigDecimal doseAmount; + + /** + * Unit for doseAmount (e.g. "mg", "mL", "units"); copied onto every generated medication_dose row. + */ + @Column(name = "dose_unit") + private String doseUnit; + + /** + * UTC instant from which this schedule version is active (inclusive). + */ + @Column(name = "effective_from", nullable = false) + private Instant effectiveFrom; + + /** + * UTC instant at which this schedule version was superseded; null means currently active. + */ + @Column(name = "effective_to") + private Instant effectiveTo; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = Instant.now(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/entities/Professional.java b/src/main/java/com/voidsoftware/map02/entities/Professional.java new file mode 100644 index 0000000..0cb7be1 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/entities/Professional.java @@ -0,0 +1,55 @@ +package com.voidsoftware.map02.entities; + +import com.voidsoftware.map02.models.MediaEntity; +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "professionals", indexes = { + @Index(name = "idx_professionals_email", columnList = "email", unique = true), + @Index(name = "idx_professional_user_id", columnList = "user_id"), + @Index(name = "idx_professional_company_name", columnList = "company_id, name"), + // Directory pagination order, not case-insensitive substring filtering. + @Index(name = "idx_professional_name_id", columnList = "name, id") +}) +@Data +@NoArgsConstructor +public class Professional implements MediaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false, columnDefinition = "uuid") + private UUID id; + + /** + * Full name of the healthcare professional. + */ + @Column(nullable = false) + private String name; + + /** + * Regulatory licence number issued by the professional's medical council. + */ + @Column(name = "license_number") + private String licenseNumber; + + + @Column + private String email; + /** + * Company (clinic/hospital) this professional belongs to. + */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + @Column(columnDefinition = "uuid") + private UUID resourceId; + + @Column(name = "user_id", columnDefinition = "uuid") + private UUID userId; +} + diff --git a/src/main/java/com/voidsoftware/map02/models/DayPeriodTypes.java b/src/main/java/com/voidsoftware/map02/models/DayPeriodTypes.java new file mode 100644 index 0000000..2dad4a8 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/DayPeriodTypes.java @@ -0,0 +1,10 @@ +package com.voidsoftware.map02.models; + +public enum DayPeriodTypes { + + MORNING, + LUNCH, + AFTERNOON, + DINNER, + BEDTIME +} diff --git a/src/main/java/com/voidsoftware/map02/models/DosageForm.java b/src/main/java/com/voidsoftware/map02/models/DosageForm.java new file mode 100644 index 0000000..ce2c983 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/DosageForm.java @@ -0,0 +1,16 @@ +package com.voidsoftware.map02.models; + +public enum DosageForm { + TABLET, + CAPSULE, + LIQUID, + INJECTION, + PATCH, + INHALER, + DROPS, + CREAM, + GEL, + SUPPOSITORY, + POWDER, + SPRAY +} diff --git a/src/main/java/com/voidsoftware/map02/models/DoseTakenState.java b/src/main/java/com/voidsoftware/map02/models/DoseTakenState.java new file mode 100644 index 0000000..19c1957 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/DoseTakenState.java @@ -0,0 +1,13 @@ +package com.voidsoftware.map02.models; + +public enum DoseTakenState { + PENDING(0), TAKEN(1), SKIPPED(2), SNOOZED(3); + + + private int stateId; + + DoseTakenState(int state) { + + this.stateId = state; + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/FrequencyType.java b/src/main/java/com/voidsoftware/map02/models/FrequencyType.java new file mode 100644 index 0000000..b68bcf3 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/FrequencyType.java @@ -0,0 +1,49 @@ +package com.voidsoftware.map02.models; + +public enum FrequencyType { + + /** + * Doses fire at specific clock times each day — uses timesOfDay list. + */ + FIXED_TIMES, + + /** + * Doses fire every N hours/days from the first dose — uses intervalValue + intervalUnit. + */ + INTERVAL, + + /** + * Once daily at a specified time. + */ + ONCE_DAILY, + + /** + * Twice daily (BID) — expects exactly 2 entries in timesOfDay. + */ + TWICE_DAILY, + + /** + * Three times daily (TID) — expects exactly 3 entries in timesOfDay. + */ + THREE_TIMES_DAILY, + + /** + * Four times daily (QID) — expects exactly 4 entries in timesOfDay. + */ + FOUR_TIMES_DAILY, + + /** + * Every other day. + */ + ALTERNATE_DAYS, + + /** + * On specific days of the week — expects a separate weekdays field (not yet on entity). + */ + WEEKLY, + + /** + * As needed — no pre-generated doses; patient logs after taking. Matches prn=true on Prescription. + */ + AS_NEEDED +} diff --git a/src/main/java/com/voidsoftware/map02/models/Gender.java b/src/main/java/com/voidsoftware/map02/models/Gender.java new file mode 100644 index 0000000..2f3b48b --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/Gender.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02.models; + +public enum Gender { + MALE, + FEMALE, + NON_BINARY, + OTHER, + PREFER_NOT_TO_SAY +} diff --git a/src/main/java/com/voidsoftware/map02/models/MediaEntity.java b/src/main/java/com/voidsoftware/map02/models/MediaEntity.java new file mode 100644 index 0000000..a99acf6 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/MediaEntity.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02.models; + +import java.util.UUID; + +public interface MediaEntity { + + + public UUID getResourceId(); +} diff --git a/src/main/java/com/voidsoftware/map02/models/Role.java b/src/main/java/com/voidsoftware/map02/models/Role.java new file mode 100644 index 0000000..18470c4 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/Role.java @@ -0,0 +1,7 @@ +package com.voidsoftware.map02.models; + +public enum Role { + PATIENT, + PROFESSIONAL, + ADMIN +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/Base64ImageFileDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/Base64ImageFileDTO.java new file mode 100644 index 0000000..03c386b --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/Base64ImageFileDTO.java @@ -0,0 +1,12 @@ +package com.voidsoftware.map02.models.dto; + +import lombok.Data; + +@Data +public class Base64ImageFileDTO { + + private String fileHash; + private String file; + private Float imageWidth; + private Float imageHeight; +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/CompanyDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/CompanyDTO.java new file mode 100644 index 0000000..1f92425 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/CompanyDTO.java @@ -0,0 +1,6 @@ +package com.voidsoftware.map02.models.dto; + +import java.util.UUID; + +public record CompanyDTO(UUID companyId, String name, String contactEmail, String companyLogo) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/DoseStateCountDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/DoseStateCountDTO.java new file mode 100644 index 0000000..f608a0b --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/DoseStateCountDTO.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02.models.dto; + +import com.voidsoftware.map02.models.DoseTakenState; + +public record DoseStateCountDTO( + DoseTakenState status, + long count +) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/MedicationDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/MedicationDTO.java new file mode 100644 index 0000000..3f4b3c1 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/MedicationDTO.java @@ -0,0 +1,15 @@ +package com.voidsoftware.map02.models.dto; + +import com.voidsoftware.map02.models.DosageForm; + +import java.util.UUID; + + +public record MedicationDTO( + UUID medicationId, + String name, + String activeIngredient, + DosageForm dosageForm, + String medicationPhoto +) { +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/models/dto/PatientDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/PatientDTO.java new file mode 100644 index 0000000..188b195 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/PatientDTO.java @@ -0,0 +1,24 @@ +package com.voidsoftware.map02.models.dto; + +import com.voidsoftware.map02.models.Gender; + +import java.time.LocalDate; +import java.util.UUID; + +public record PatientDTO( + UUID id, + String name, + String email, + String phoneNumber, + String address, + LocalDate dateOfBirth, + String timezone, + Gender gender, + String emergencyContactName, + String emergencyContactPhone, + String notes, + UUID professionalId, + String photo + +) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/PatientDoseHistoryDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/PatientDoseHistoryDTO.java new file mode 100644 index 0000000..85e07ed --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/PatientDoseHistoryDTO.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02.models.dto; + +import com.voidsoftware.map02.models.DoseTakenState; +import java.time.Instant; + +/** Lightweight history projection; no lazy entities are passed to the template. */ +public record PatientDoseHistoryDTO(Instant scheduledAt, Instant takenAt, + DoseTakenState status, String medicationName) { +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/models/dto/PatientPeriodDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/PatientPeriodDTO.java new file mode 100644 index 0000000..9c984bc --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/PatientPeriodDTO.java @@ -0,0 +1,12 @@ +package com.voidsoftware.map02.models.dto; + +import com.voidsoftware.map02.models.DayPeriodTypes; + +import java.time.LocalTime; + +public record PatientPeriodDTO( + + DayPeriodTypes periodType, + LocalTime timeOfDay +) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/PrescriptionDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/PrescriptionDTO.java new file mode 100644 index 0000000..b4aa830 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/PrescriptionDTO.java @@ -0,0 +1,4 @@ +package com.voidsoftware.map02.models.dto; + +public record PrescriptionDTO() { +} diff --git a/src/main/java/com/voidsoftware/map02/models/dto/ProfessionalDTO.java b/src/main/java/com/voidsoftware/map02/models/dto/ProfessionalDTO.java new file mode 100644 index 0000000..b325c13 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/dto/ProfessionalDTO.java @@ -0,0 +1,11 @@ +package com.voidsoftware.map02.models.dto; + +import java.util.UUID; + +public record ProfessionalDTO(UUID professionalId, + String email, + String name, + UUID companyId, + String photo +) { +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ChangeMediaEntityImageRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/ChangeMediaEntityImageRequest.java new file mode 100644 index 0000000..5899577 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ChangeMediaEntityImageRequest.java @@ -0,0 +1,11 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.Base64ImageFileDTO; + +import java.util.UUID; + +public record ChangeMediaEntityImageRequest(UUID entityId, + Base64ImageFileDTO patientPhoto) { + + +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ChangePrescriptionScheduleRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/ChangePrescriptionScheduleRequest.java new file mode 100644 index 0000000..11322e5 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ChangePrescriptionScheduleRequest.java @@ -0,0 +1,58 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.validation.ValidZoneId; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Data +public class ChangePrescriptionScheduleRequest { + + @NotNull + private UUID prescriptionId; + @NotNull + private FrequencyType frequencyType; + + /** + * Required when frequencyType = INTERVAL. + */ + private Integer intervalValue; + + /** + * Required when frequencyType = INTERVAL (e.g. "hours", "days"). + */ + private String intervalUnit; + + /** + * Required when frequencyType = FIXED_TIMES / TWICE_DAILY / etc. Local times, e.g. "08:00". + */ + private List timesOfDay; + + @NotNull + @DecimalMin(value = "0.0001", message = "Dose amount must be positive") + private BigDecimal doseAmount; + + @NotBlank + private String doseUnit; + + /** + * First day the new schedule applies; must be in the future. + */ + @NotNull + @FutureOrPresent + private LocalDate effectiveFrom; + + /** + * Overrides the patient's stored timezone for effectiveFrom conversion. + */ + @ValidZoneId + private String timezone; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/CreateCompanyRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/CreateCompanyRequest.java new file mode 100644 index 0000000..1ebd061 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/CreateCompanyRequest.java @@ -0,0 +1,17 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.NotBlank; + + +public record CreateCompanyRequest( + + @NotBlank(message = "Name is required") + String name, + + String vatNumber, + + String contactEmail, + + String companyLogo) { + +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/CreateMedicationRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/CreateMedicationRequest.java new file mode 100644 index 0000000..d9f492a --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/CreateMedicationRequest.java @@ -0,0 +1,15 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.DosageForm; +import jakarta.validation.constraints.NotBlank; + +public record CreateMedicationRequest( + @NotBlank(message = "Name is required") + String name, + String activeIngredient, + DosageForm dosageForm, + String strength +) { + + +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/CreatePatientRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/CreatePatientRequest.java new file mode 100644 index 0000000..d10d023 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/CreatePatientRequest.java @@ -0,0 +1,30 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.Base64ImageFileDTO; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +public class CreatePatientRequest { + + @NotBlank(message = "Name is required") + private String name; + + private LocalDate dateOfBirth; + + @NotBlank(message = "Timezone is required") + private String timezone; + + private Boolean active = true; + + @NotNull(message = "Professional id is required") + private UUID professionalId; + + private Base64ImageFileDTO patientPhoto; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/CreatePrescriptionRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/CreatePrescriptionRequest.java new file mode 100644 index 0000000..c23998b --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/CreatePrescriptionRequest.java @@ -0,0 +1,72 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.validation.ValidZoneId; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +public class CreatePrescriptionRequest { + + @NotNull + private UUID patientId; + + @NotNull + private UUID medicationId; + + /** + * The prescribing professional. Can be omitted and resolved from the session user instead. + */ + @NotNull + private UUID prescriberId; + + @NotNull + private LocalDate startDate; + + private LocalDate endDate; + + private boolean prn = false; + + private String instructions; + + @ValidZoneId + private String timezone; + + + @NotNull + private FrequencyType frequencyType; + + /** + * Required when frequencyType = INTERVAL. + */ + private Integer intervalValue; + + /** + * Required when frequencyType = INTERVAL (e.g. "hours", "days"). + */ + private String intervalUnit; + + /** + * Required when frequencyType = FIXED_TIMES / TWICE_DAILY / etc. Local times, e.g. "08:00". + */ + private List timesOfDay; + + @NotNull + @DecimalMin(value = "0.0001", message = "Dose amount must be positive") + private BigDecimal doseAmount; + + @NotBlank + private String doseUnit; + + +} + diff --git a/src/main/java/com/voidsoftware/map02/models/inout/CreateProfessionalRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/CreateProfessionalRequest.java new file mode 100644 index 0000000..637c8fa --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/CreateProfessionalRequest.java @@ -0,0 +1,27 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.Base64ImageFileDTO; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class CreateProfessionalRequest { + + @NotBlank(message = "Name is required") + private String name; + + private String licenseNumber; + + @NotNull(message = "Company id is required") + private UUID companyId; + + @NotNull(message = "email is required") + private String email; + + private Base64ImageFileDTO patientPhoto; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/GenerateDosesRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/GenerateDosesRequest.java new file mode 100644 index 0000000..80796af --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/GenerateDosesRequest.java @@ -0,0 +1,28 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +public class GenerateDosesRequest { + + // @NotNull + private UUID prescriptionId; + + /** + * First local calendar day (inclusive) to generate doses for. + */ + @NotNull + private LocalDate fromDate; + + /** + * Last local calendar day (inclusive) to generate doses for. + */ + @NotNull + private LocalDate toDate; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListOfCompaniesResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/ListOfCompaniesResponse.java new file mode 100644 index 0000000..0cf74f2 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListOfCompaniesResponse.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.CompanyDTO; + +import java.util.List; + +public record ListOfCompaniesResponse(List companies) { + +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListOfMedicationsResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/ListOfMedicationsResponse.java new file mode 100644 index 0000000..f0e6aab --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListOfMedicationsResponse.java @@ -0,0 +1,8 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.MedicationDTO; + +import java.util.List; + +public record ListOfMedicationsResponse(List medications) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientPeriods.java b/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientPeriods.java new file mode 100644 index 0000000..070fa11 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientPeriods.java @@ -0,0 +1,8 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.PatientPeriodDTO; + +import java.util.List; + +public record ListOfPatientPeriods(List periods) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientsResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientsResponse.java new file mode 100644 index 0000000..6900114 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListOfPatientsResponse.java @@ -0,0 +1,8 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.PatientDTO; + +import java.util.List; + +public record ListOfPatientsResponse(List patients) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListOfProfessionalsResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/ListOfProfessionalsResponse.java new file mode 100644 index 0000000..e04a76c --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListOfProfessionalsResponse.java @@ -0,0 +1,8 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.dto.ProfessionalDTO; + +import java.util.List; + +public record ListOfProfessionalsResponse(List professionals) { +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListPatientsRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/ListPatientsRequest.java new file mode 100644 index 0000000..be2db67 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListPatientsRequest.java @@ -0,0 +1,15 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class ListPatientsRequest { + + @NotNull + private UUID professionalId; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListPrescriptionsRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/ListPrescriptionsRequest.java new file mode 100644 index 0000000..5a5d1f3 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListPrescriptionsRequest.java @@ -0,0 +1,28 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class ListPrescriptionsRequest { + + @NotNull + private UUID patientId; + + /** When true only prescriptions with status = ACTIVE are returned; false returns all. */ + private boolean activeOnly = false; + + /** Controls which schedule versions are included in each prescription's schedules list. */ + private ScheduleFilter scheduleFilter = ScheduleFilter.ACTIVE_ONLY; + + public enum ScheduleFilter { + /** Include only the open schedule version (effectiveTo IS NULL). */ + ACTIVE_ONLY, + /** Include every historical and current schedule version. */ + ALL + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/ListProfessionalsRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/ListProfessionalsRequest.java new file mode 100644 index 0000000..16403a8 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/ListProfessionalsRequest.java @@ -0,0 +1,15 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class ListProfessionalsRequest { + + @NotNull + private UUID companyId; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/LoginRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/LoginRequest.java new file mode 100644 index 0000000..ac6cc1d --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/LoginRequest.java @@ -0,0 +1,18 @@ +package com.voidsoftware.map02.models.inout; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginRequest { + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/LoginResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/LoginResponse.java new file mode 100644 index 0000000..deb91fb --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/LoginResponse.java @@ -0,0 +1,14 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.Role; + +import java.util.UUID; + +public record LoginResponse(UUID id, String email, Role role) { + + public static LoginResponse from(AppUser user) { + + return new LoginResponse(user.getId(), user.getEmail(), user.getRole()); + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java b/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java new file mode 100644 index 0000000..2cee891 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/PrescriptionWithSchedulesResponse.java @@ -0,0 +1,21 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.entities.PrescriptionSchedule; +import lombok.Getter; + +import java.util.List; + +/** Bundles a prescription with the schedule versions selected by the caller's filter. */ +@Getter +public class PrescriptionWithSchedulesResponse { + + private final Prescription prescription; + private final List schedules; + + public PrescriptionWithSchedulesResponse(Prescription prescription, + List schedules) { + this.prescription = prescription; + this.schedules = schedules; + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/RecordDoseEventRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/RecordDoseEventRequest.java new file mode 100644 index 0000000..b09bc93 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/RecordDoseEventRequest.java @@ -0,0 +1,20 @@ +package com.voidsoftware.map02.models.inout; + +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +public class RecordDoseEventRequest { + + /** When the event actually happened; defaults to now if omitted. */ + private Instant occurredAt; + + /** Free-text reason, used for SKIPPED events. */ + private String reason; + + /** Minutes to push the dose back, used for SNOOZED events. */ + private Integer snoozeMinutes; +} diff --git a/src/main/java/com/voidsoftware/map02/models/inout/SignupRequest.java b/src/main/java/com/voidsoftware/map02/models/inout/SignupRequest.java new file mode 100644 index 0000000..c3877e7 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/inout/SignupRequest.java @@ -0,0 +1,25 @@ +package com.voidsoftware.map02.models.inout; + +import com.voidsoftware.map02.models.Role; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SignupRequest { + + @NotBlank + @Email + private String email; + + @NotBlank + @Size(min = 8, message = "Password must be at least 8 characters") + private String password; + + @NotNull + private Role role; +} diff --git a/src/main/java/com/voidsoftware/map02/models/view/AdminBoardView.java b/src/main/java/com/voidsoftware/map02/models/view/AdminBoardView.java new file mode 100644 index 0000000..5b9d676 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/AdminBoardView.java @@ -0,0 +1,60 @@ +package com.voidsoftware.map02.models.view; + +import lombok.Value; +import org.springframework.data.domain.Page; +import java.util.List; +import java.util.UUID; + +@Value +public class AdminBoardView { + String tab; + String query; + long userCount; + long companyCount; + long professionalCount; + long patientCount; + Page users; + Page companies; + Page professionals; + Page patients; + + public Page getResults() { + return switch (tab) { + case "companies" -> companies; + case "professionals" -> professionals; + case "patients" -> patients; + default -> users; + }; + } + + @Value + public static class CompanyItem { + UUID id; + String name; + String vatNumber; + String email; + List professionals; + public long getPatientCount() { return professionals.stream().mapToLong(p -> p.getPatients().size()).sum(); } + } + + @Value + public static class ProfessionalItem { + UUID id; + String name; + String email; + String licenseNumber; + String companyName; + List patients; + } + + @Value + public static class PatientItem { + UUID id; + String name; + String email; + String phone; + boolean active; + String professionalName; + String companyName; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/models/view/AdminUserView.java b/src/main/java/com/voidsoftware/map02/models/view/AdminUserView.java new file mode 100644 index 0000000..30c9ea4 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/AdminUserView.java @@ -0,0 +1,25 @@ +package com.voidsoftware.map02.models.view; + +import com.voidsoftware.map02.models.Role; +import lombok.Value; +import java.time.Instant; +import java.util.UUID; + +/** Explicit projection: credentials must never be loaded into the board model. */ +@Value +public class AdminUserView { + UUID id; + String email; + Role role; + boolean enabled; + Instant createdAt; + UUID entityId; + + public String getRoleLabel() { + return switch (role) { + case ADMIN -> "Administrador"; + case PROFESSIONAL -> "Profissional"; + case PATIENT -> "Paciente"; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/models/view/AlertItemView.java b/src/main/java/com/voidsoftware/map02/models/view/AlertItemView.java new file mode 100644 index 0000000..97d27f5 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/AlertItemView.java @@ -0,0 +1,29 @@ +package com.voidsoftware.map02.models.view; + +import lombok.Getter; + +import java.util.UUID; + +/** One row in the dashboard's "Alertas" strip — a recent missed dose or a dose taken late. */ +@Getter +public class AlertItemView { + + private final UUID patientId; + private final String patientName; + private final String patientPhoto; + private final String message; + private final String timeAgo; + + /** "critical" | "warning" | "info" — matches the .dot-* CSS modifiers in base.css. */ + private final String severity; + + public AlertItemView(UUID patientId, String patientName, String patientPhoto, String message, + String timeAgo, String severity) { + this.patientId = patientId; + this.patientName = patientName; + this.patientPhoto = patientPhoto; + this.message = message; + this.timeAgo = timeAgo; + this.severity = severity; + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/view/DashboardViewModel.java b/src/main/java/com/voidsoftware/map02/models/view/DashboardViewModel.java new file mode 100644 index 0000000..1fd1a05 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/DashboardViewModel.java @@ -0,0 +1,27 @@ +package com.voidsoftware.map02.models.view; + +import lombok.Getter; + +import java.util.List; + +/** Everything the professional dashboard template needs to render in a single pass. */ +@Getter +public class DashboardViewModel { + + private final String professionalName; + private final String professionalPhoto; + private final int adherencePercent; + private final int nonAdherencePercent; + private final List alerts; + private final List patients; + + public DashboardViewModel(String professionalName, String professionalPhoto, int adherencePercent, + int nonAdherencePercent, List alerts, List patients) { + this.professionalName = professionalName; + this.professionalPhoto = professionalPhoto; + this.adherencePercent = adherencePercent; + this.nonAdherencePercent = nonAdherencePercent; + this.alerts = alerts; + this.patients = patients; + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/view/PatientCardView.java b/src/main/java/com/voidsoftware/map02/models/view/PatientCardView.java new file mode 100644 index 0000000..a919e96 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/PatientCardView.java @@ -0,0 +1,32 @@ +package com.voidsoftware.map02.models.view; + +import lombok.Getter; + +import java.util.List; +import java.util.UUID; + +/** One patient card on the professional dashboard. */ +@Getter +public class PatientCardView { + + private final UUID patientId; + private final String name; + private final Integer age; + private final String photo; + private final List medicationTags; + private final long todayDoseCount; + private final String nextDoseTime; + private final String ringStyle; + + public PatientCardView(UUID patientId, String name, Integer age, String photo, List medicationTags, + long todayDoseCount, String nextDoseTime, String ringStyle) { + this.patientId = patientId; + this.name = name; + this.age = age; + this.photo = photo; + this.medicationTags = medicationTags; + this.todayDoseCount = todayDoseCount; + this.nextDoseTime = nextDoseTime; + this.ringStyle = ringStyle; + } +} diff --git a/src/main/java/com/voidsoftware/map02/models/view/PatientDetailView.java b/src/main/java/com/voidsoftware/map02/models/view/PatientDetailView.java new file mode 100644 index 0000000..e7418b6 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/models/view/PatientDetailView.java @@ -0,0 +1,67 @@ +package com.voidsoftware.map02.models.view; + +import lombok.Value; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Value +public class PatientDetailView { + UUID patientId; + String name; + String initial; + Integer age; + String photo; + String phone; + String timezone; + List medicationTags; + String mode; + LocalDate selectedDate; + String periodLabel; + LocalDate previousDate; + LocalDate nextDate; + List periodLinks; + int leadingDays; + int adherencePercent; + long takenCount; + long totalCount; + List days; + List history; + List prescriptions; + + @Value + public static class Day { + LocalDate date; + String number; + String weekday; + long total; + long taken; + int percent; + String tone; + String label; + boolean today; + } + + @Value + public static class Dose { + String date; + String scheduledTime; + String medication; + String status; + String tone; + String takenTime; + } + + @Value + public static class PrescriptionItem { + String name; + String strength; + String photo; + String amount; + List times; + String frequency; + String stock; + String stockTone; + String instructions; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/repositories/AppUserRepository.java b/src/main/java/com/voidsoftware/map02/repositories/AppUserRepository.java new file mode 100644 index 0000000..9e06592 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/AppUserRepository.java @@ -0,0 +1,38 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.AppUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import com.voidsoftware.map02.models.view.AdminUserView; +import com.voidsoftware.map02.models.Role; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; +import java.util.List; + +import java.util.Optional; +import java.util.UUID; + +public interface AppUserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByIdAndEnabledTrue(UUID id); + + /** Lock all administrators in stable order so concurrent removals cannot remove the last one. */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM AppUser u WHERE u.role = :role ORDER BY u.id") + List lockByRole(@Param("role") Role role); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM AppUser u WHERE u.id = :id") + Optional findForUpdate(@Param("id") UUID id); + + @Query("SELECT new com.voidsoftware.map02.models.view.AdminUserView(u.id, u.email, u.role, u.enabled, u.createdAt, u.entityId) " + + "FROM AppUser u WHERE LOWER(u.email) LIKE LOWER(CONCAT('%', :query, '%'))") + Page findAdminUsers(@Param("query") String query, Pageable pageable); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/CompanyRepository.java b/src/main/java/com/voidsoftware/map02/repositories/CompanyRepository.java new file mode 100644 index 0000000..b9142e3 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/CompanyRepository.java @@ -0,0 +1,14 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.Company; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.UUID; + +@Repository +public interface CompanyRepository extends JpaRepository { + Page findByNameContainingIgnoreCase(String name, Pageable pageable); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/DoseEventRepository.java b/src/main/java/com/voidsoftware/map02/repositories/DoseEventRepository.java new file mode 100644 index 0000000..fec3b1a --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/DoseEventRepository.java @@ -0,0 +1,11 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.DoseEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface DoseEventRepository extends JpaRepository { +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/DoseGenerationRunRepository.java b/src/main/java/com/voidsoftware/map02/repositories/DoseGenerationRunRepository.java new file mode 100644 index 0000000..dcde72c --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/DoseGenerationRunRepository.java @@ -0,0 +1,17 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.DoseGenerationRun; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DoseGenerationRunRepository extends JpaRepository { + + boolean existsByRunDate(LocalDate runDate); + + Optional findByRunDate(LocalDate runDate); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/MediaResourceRepository.java b/src/main/java/com/voidsoftware/map02/repositories/MediaResourceRepository.java new file mode 100644 index 0000000..4ab5542 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/MediaResourceRepository.java @@ -0,0 +1,15 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.MediaResource; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface MediaResourceRepository extends JpaRepository { + + /** Lookup by content hash to avoid storing duplicate files. */ + Optional findByHash(String hash); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/MedicationAlertRepository.java b/src/main/java/com/voidsoftware/map02/repositories/MedicationAlertRepository.java new file mode 100644 index 0000000..d3cf1d8 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/MedicationAlertRepository.java @@ -0,0 +1,23 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.MedicationAlert; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface MedicationAlertRepository extends JpaRepository { + + boolean existsByMedicationDoseIdAndAlertType(UUID medicationDoseId, String alertType); + + boolean existsByPatientIdAndAlertTypeAndStatus(UUID patientId, String alertType, String status); + + /** Most recent alerts across every patient of a professional; feeds the dashboard activity strip. */ + @Query("SELECT a FROM MedicationAlert a WHERE a.patient.professional.id = :professionalId ORDER BY a.scheduledAt DESC") + List findRecentByProfessionalId(@Param("professionalId") UUID professionalId, Pageable pageable); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/MedicationDoseRepository.java b/src/main/java/com/voidsoftware/map02/repositories/MedicationDoseRepository.java new file mode 100644 index 0000000..a083468 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/MedicationDoseRepository.java @@ -0,0 +1,135 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.dto.DoseStateCountDTO; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Repository +public interface MedicationDoseRepository extends JpaRepository { + + /** Half-open UTC window, grouped into local calendar days by the detail service. */ + @Query(""" + SELECT new com.voidsoftware.map02.models.dto.PatientDoseHistoryDTO( + d.scheduledAt, d.takenAt, d.status, d.prescription.medication.name) + FROM MedicationDose d + WHERE d.prescription.patient.id = :patientId + AND d.scheduledAt >= :from AND d.scheduledAt < :to + ORDER BY d.scheduledAt, d.prescription.medication.name, d.id + """) + List findPatientHistory( + @Param("patientId") UUID patientId, @Param("from") Instant from, @Param("to") Instant to); + + /** + * Used by the generator to avoid inserting a duplicate dose for the same instant. + */ + boolean existsByPrescriptionIdAndScheduledAt(UUID prescriptionId, Instant scheduledAt); + + /** + * All doses for a prescription within a window, in chronological order. + */ + List findByPrescriptionIdAndScheduledAtBetweenOrderByScheduledAtAsc( + UUID prescriptionId, Instant from, Instant to); + + /** + * Every dose ever generated for a prescription, in chronological order. + */ + List findByPrescriptionIdOrderByScheduledAtAsc(UUID prescriptionId); + + /** + * Still-pending doses whose scheduled time has already passed; candidates for a MISSED_DOSE sweep. + */ + List findByStatusAndScheduledAtBefore(DoseTakenState status, Instant cutoff); + + /** + * Still-pending doses whose server-local scheduled time has already passed; candidates for a MISSED_DOSE sweep. + */ + List findByStatusAndScheduledAtServerTimeBefore(DoseTakenState status, java.time.LocalDateTime cutoff); + + /** + * Same as {@link #findByStatusAndScheduledAtServerTimeBefore}, scoped to a single patient. + */ + @Query("SELECT d FROM MedicationDose d WHERE d.prescription.patient.id = :patientId AND d.status = :status AND d.scheduledAtServerTime < :cutoff") + List findByPatientIdAndStatusAndScheduledAtServerTimeBefore(@Param("patientId") UUID patientId, + @Param("status") DoseTakenState status, + @Param("cutoff") java.time.LocalDateTime cutoff); + + /** + * Bulk-updates every expired dose still in {@code status} to {@code newStatus}; returns rows affected. + */ + @Modifying + @Query("UPDATE MedicationDose d SET d.status = :newStatus WHERE d.status = :status AND d.scheduledAtServerTime < :cutoff") + int updateStatusForExpired(@Param("status") DoseTakenState status, + @Param("newStatus") DoseTakenState newStatus, + @Param("cutoff") java.time.LocalDateTime cutoff); + + /** + * Same as {@link #updateStatusForExpired}, scoped to a single patient. + */ + @Modifying + @Query("UPDATE MedicationDose d SET d.status = :newStatus WHERE d.prescription.patient.id = :patientId" + + " AND d.status = :status AND d.scheduledAtServerTime < :cutoff") + int updateStatusForExpiredByPatientId(@Param("patientId") UUID patientId, + @Param("status") DoseTakenState status, + @Param("newStatus") DoseTakenState newStatus, + @Param("cutoff") java.time.LocalDateTime cutoff); + + /** + * Every decided (TAKEN/SKIPPED) dose across all patients of a professional; used to compute adherence rate. + */ + @Query("SELECT d FROM MedicationDose d WHERE d.prescription.patient.professional.id = :professionalId AND d.status IN :statuses") + List findByProfessionalIdAndStatusIn(@Param("professionalId") UUID professionalId, + @Param("statuses") Collection statuses); + + /** + * How many doses a given patient has scheduled within a window (e.g. "today"). + */ + @Query("SELECT COUNT(d) FROM MedicationDose d WHERE d.prescription.patient.id = :patientId AND d.scheduledAt BETWEEN :from AND :to") + long countByPatientIdAndScheduledAtBetween(@Param("patientId") UUID patientId, + @Param("from") Instant from, + @Param("to") Instant to); + + /** + * The soonest still-pending dose for a patient at or after the given instant. + */ + @Query("SELECT d FROM MedicationDose d WHERE d.prescription.patient.id = :patientId AND d.status = com.voidsoftware.map02.models.DoseTakenState.PENDING AND d.scheduledAt >= :from ORDER BY d.scheduledAt ASC") + List findUpcomingPendingByPatientId(@Param("patientId") UUID patientId, + @Param("from") Instant from, + Pageable pageable); + + /** + * Most recently taken doses across a professional's patients, most recent first; used for the activity feed. + */ + @Query("SELECT d FROM MedicationDose d WHERE d.prescription.patient.professional.id = :professionalId AND d.status = com.voidsoftware.map02.models.DoseTakenState.TAKEN AND d.takenAt IS NOT NULL ORDER BY d.takenAt DESC") + List findRecentTakenByProfessionalId(@Param("professionalId") UUID professionalId, Pageable pageable); + + /** + * Dose counts per status for a patient within a window; used for adherence breakdowns/charts. + */ + @Query("SELECT new com.voidsoftware.map02.models.dto.DoseStateCountDTO(d.status, COUNT(d)) FROM MedicationDose d " + + "WHERE d.prescription.patient.id = :patientId AND d.scheduledAt BETWEEN :from AND :to " + + "GROUP BY d.status") + List countByPatientIdAndScheduledAtBetweenGroupByStatus(@Param("patientId") UUID patientId, + @Param("from") Instant from, + @Param("to") Instant to); + + + /** + * Dose counts per status for a patient, across all time; used for adherence breakdowns/charts. + */ + @Query("SELECT new com.voidsoftware.map02.models.dto.DoseStateCountDTO(d.status, COUNT(d)) FROM MedicationDose d " + + "WHERE d.prescription.patient.id = :patientId AND d.scheduledAtServerTime < :cutoff" + + " GROUP BY d.status") + List countByPatientIdBetweenGroupByStatus(@Param("patientId") UUID patientId, + @Param("cutoff") java.time.LocalDateTime cutoff); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/MedicationInventoryRepository.java b/src/main/java/com/voidsoftware/map02/repositories/MedicationInventoryRepository.java new file mode 100644 index 0000000..b4e61ed --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/MedicationInventoryRepository.java @@ -0,0 +1,18 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.MedicationInventory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +@Repository +public interface MedicationInventoryRepository extends JpaRepository { + + Optional findByPrescriptionId(UUID prescriptionId); + + List findByPrescriptionIdIn(Collection prescriptionIds); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/MedicationRepository.java b/src/main/java/com/voidsoftware/map02/repositories/MedicationRepository.java new file mode 100644 index 0000000..78fe34d --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/MedicationRepository.java @@ -0,0 +1,11 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.Medication; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface MedicationRepository extends JpaRepository { +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/NotificationPreferenceRepository.java b/src/main/java/com/voidsoftware/map02/repositories/NotificationPreferenceRepository.java new file mode 100644 index 0000000..80f2207 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/NotificationPreferenceRepository.java @@ -0,0 +1,11 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.NotificationPreference; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface NotificationPreferenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/PatientPeriodsRepository.java b/src/main/java/com/voidsoftware/map02/repositories/PatientPeriodsRepository.java new file mode 100644 index 0000000..2ac1073 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/PatientPeriodsRepository.java @@ -0,0 +1,16 @@ +package com.voidsoftware.map02.repositories; + + +import com.voidsoftware.map02.entities.PatientPeriods; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PatientPeriodsRepository extends JpaRepository { + + + List findByPatientId(UUID patientId); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/PatientRepository.java b/src/main/java/com/voidsoftware/map02/repositories/PatientRepository.java new file mode 100644 index 0000000..42bd7e9 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/PatientRepository.java @@ -0,0 +1,34 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.Patient; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.Collection; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PatientRepository extends JpaRepository { + + List findByProfessionalId(UUID professionalId); + + @EntityGraph(attributePaths = {"professional", "professional.company"}) + List findByProfessionalIdInOrderByNameAsc(Collection professionalIds); + + @EntityGraph(attributePaths = {"professional", "professional.company"}) + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + Optional findByEmail(String email); + + + Optional findByUserId(UUID userId); + + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query("UPDATE Patient p SET p.userId = NULL WHERE p.userId = :userId") + int unlinkAppUser(@org.springframework.data.repository.query.Param("userId") UUID userId); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/PrescriptionRepository.java b/src/main/java/com/voidsoftware/map02/repositories/PrescriptionRepository.java new file mode 100644 index 0000000..1df5eeb --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/PrescriptionRepository.java @@ -0,0 +1,21 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.Prescription; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface PrescriptionRepository extends JpaRepository { + + List findByPatientId(UUID patientId); + + @EntityGraph(attributePaths = "medication") + List findByPatientIdAndStatus(UUID patientId, String status); + + /** All prescriptions with the given status, across every patient; used by the daily dose generation sweep. */ + List findByStatus(String status); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/PrescriptionScheduleRepository.java b/src/main/java/com/voidsoftware/map02/repositories/PrescriptionScheduleRepository.java new file mode 100644 index 0000000..dd7f1ff --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/PrescriptionScheduleRepository.java @@ -0,0 +1,42 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.PrescriptionSchedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.time.Instant; +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PrescriptionScheduleRepository extends JpaRepository { + + /** Fetch current schedules and clock times for all displayed prescriptions in one query. */ + @Query(""" + SELECT DISTINCT s FROM PrescriptionSchedule s LEFT JOIN FETCH s.timesOfDay + WHERE s.prescription.id IN :ids AND s.effectiveFrom <= :now + AND (s.effectiveTo IS NULL OR s.effectiveTo > :now) + ORDER BY s.version DESC + """) + List findEffectiveForPrescriptions(@Param("ids") Collection ids, + @Param("now") Instant now); + + /** Returns the currently open schedule version (effectiveTo IS NULL) for a prescription. */ + @Query("SELECT s FROM PrescriptionSchedule s WHERE s.prescription.id = :prescriptionId AND s.effectiveTo IS NULL ORDER BY s.version DESC") + Optional findActiveByPrescriptionId(@Param("prescriptionId") UUID prescriptionId); + + /** Returns the highest version number recorded for a prescription. */ + @Query("SELECT COALESCE(MAX(s.version), 0) FROM PrescriptionSchedule s WHERE s.prescription.id = :prescriptionId") + int findMaxVersionByPrescriptionId(@Param("prescriptionId") UUID prescriptionId); + + /** Returns all schedule versions for a prescription ordered by version ascending. */ + List findByPrescriptionIdOrderByVersionAsc(UUID prescriptionId); + + /** Returns only the active schedule version (effectiveTo IS NULL) as a list for uniform handling. */ + @Query("SELECT s FROM PrescriptionSchedule s WHERE s.prescription.id = :prescriptionId AND s.effectiveTo IS NULL") + List findActiveListByPrescriptionId(@Param("prescriptionId") UUID prescriptionId); +} diff --git a/src/main/java/com/voidsoftware/map02/repositories/ProfessionalRepository.java b/src/main/java/com/voidsoftware/map02/repositories/ProfessionalRepository.java new file mode 100644 index 0000000..35a2bcb --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/repositories/ProfessionalRepository.java @@ -0,0 +1,31 @@ +package com.voidsoftware.map02.repositories; + +import com.voidsoftware.map02.entities.Professional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.Collection; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface ProfessionalRepository extends JpaRepository { + + Optional findByEmail(String email); + + List findByCompanyId(UUID companyId); + + @EntityGraph(attributePaths = "company") + List findByCompanyIdInOrderByNameAsc(Collection companyIds); + + @EntityGraph(attributePaths = "company") + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query("UPDATE Professional p SET p.userId = NULL WHERE p.userId = :userId") + int unlinkAppUser(@org.springframework.data.repository.query.Param("userId") UUID userId); +} diff --git a/src/main/java/com/voidsoftware/map02/services/AdminBoardService.java b/src/main/java/com/voidsoftware/map02/services/AdminBoardService.java new file mode 100644 index 0000000..51b8a20 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/AdminBoardService.java @@ -0,0 +1,96 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.view.AdminBoardView; +import com.voidsoftware.map02.models.view.AdminBoardView.*; +import com.voidsoftware.map02.models.view.AdminUserView; +import com.voidsoftware.map02.repositories.*; +import org.springframework.data.domain.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@PreAuthorize("hasRole('ADMIN')") +public class AdminBoardService { + private static final int PAGE_SIZE = 20; + private final AppUserRepository users; + private final CompanyRepository companies; + private final ProfessionalRepository professionals; + private final PatientRepository patients; + + public AdminBoardService(AppUserRepository users, CompanyRepository companies, + ProfessionalRepository professionals, PatientRepository patients) { + this.users = users; + this.companies = companies; + this.professionals = professionals; + this.patients = patients; + } + + @Transactional(readOnly = true) + public AdminBoardView build(String requestedTab, String query, int page) { + String tab = Set.of("users", "companies", "professionals", "patients").contains(requestedTab) ? requestedTab : "users"; + String search = query == null ? "" : query.strip(); + Page userPage = Page.empty(); + Page companyPage = Page.empty(); + Page professionalPage = Page.empty(); + Page patientPage = Page.empty(); + Pageable pageable = PageRequest.of(Math.max(0, page), PAGE_SIZE, Sort.by(tab.equals("users") ? "email" : "name").ascending().and(Sort.by("id"))); + + switch (tab) { + case "users" -> userPage = boundedPage(pageable, p -> users.findAdminUsers(search, p)); + case "companies" -> { + var result = boundedPage(pageable, p -> companies.findByNameContainingIgnoreCase(search, p)); + List staff = result.isEmpty() ? List.of() : professionals.findByCompanyIdInOrderByNameAsc( + result.getContent().stream().map(c -> c.getId()).toList()); + Map> assigned = patientsByProfessional(staff); + Map> byCompany = staff.stream().collect(Collectors.groupingBy( + p -> p.getCompany().getId(), Collectors.mapping(p -> professionalItem(p, assigned), Collectors.toList()))); + companyPage = result.map(c -> new CompanyItem(c.getId(), c.getName(), c.getVatNumber(), c.getContactEmail(), + byCompany.getOrDefault(c.getId(), List.of()))); + } + case "professionals" -> { + var result = boundedPage(pageable, p -> professionals.findByNameContainingIgnoreCase(search, p)); + Map> assigned = patientsByProfessional(result.getContent()); + professionalPage = result.map(p -> professionalItem(p, assigned)); + } + case "patients" -> patientPage = boundedPage(pageable, p -> patients.findByNameContainingIgnoreCase(search, p)).map(this::patientItem); + } + return new AdminBoardView(tab, search, users.count(), companies.count(), professionals.count(), patients.count(), + userPage, companyPage, professionalPage, patientPage); + } + + /** Clamp stale page links after records have been removed. */ + private Page boundedPage(Pageable pageable, Function> query) { + Page result = query.apply(pageable); + if (result.getTotalPages() > 0 && pageable.getPageNumber() >= result.getTotalPages()) { + return query.apply(PageRequest.of(result.getTotalPages() - 1, PAGE_SIZE, pageable.getSort())); + } + return result; + } + + private Map> patientsByProfessional(List staff) { + if (staff.isEmpty()) return Map.of(); + return patients.findByProfessionalIdInOrderByNameAsc(staff.stream().map(Professional::getId).toList()) + .stream().collect(Collectors.groupingBy(p -> p.getProfessional().getId(), + Collectors.mapping(this::patientItem, Collectors.toList()))); + } + + private ProfessionalItem professionalItem(Professional professional, Map> assigned) { + return new ProfessionalItem(professional.getId(), professional.getName(), professional.getEmail(), + professional.getLicenseNumber(), professional.getCompany() == null ? "Sem empresa" : professional.getCompany().getName(), + assigned.getOrDefault(professional.getId(), List.of())); + } + + private PatientItem patientItem(Patient patient) { + Professional professional = patient.getProfessional(); + return new PatientItem(patient.getId(), patient.getName(), patient.getEmail(), patient.getPhoneNumber(), + Boolean.TRUE.equals(patient.getActive()), professional == null ? "Não atribuído" : professional.getName(), + professional == null || professional.getCompany() == null ? "Sem empresa" : professional.getCompany().getName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/services/AdminUserManagementService.java b/src/main/java/com/voidsoftware/map02/services/AdminUserManagementService.java new file mode 100644 index 0000000..88b8ef2 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/AdminUserManagementService.java @@ -0,0 +1,71 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.repositories.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@PreAuthorize("hasRole('ADMIN')") +public class AdminUserManagementService { + private final AppUserRepository users; + private final PatientRepository patients; + private final ProfessionalRepository professionals; + + public AdminUserManagementService(AppUserRepository users, PatientRepository patients, ProfessionalRepository professionals) { + this.users = users; + this.patients = patients; + this.professionals = professionals; + } + + @Transactional + public void setEnabled(UUID id, boolean enabled, String actorEmail) { + List admins = users.lockByRole(Role.ADMIN); + AppUser actor = requireActiveAdmin(admins, actorEmail); + AppUser target = requireUser(id); + if (!enabled) protectAccount(target, actor, admins); + target.setEnabled(enabled); + users.save(target); + log.info("Administrator {} set account {} enabled={}", actor.getId(), id, enabled); + } + + @Transactional + public void delete(UUID id, String actorEmail) { + List admins = users.lockByRole(Role.ADMIN); + AppUser actor = requireActiveAdmin(admins, actorEmail); + AppUser target = requireUser(id); + protectAccount(target, actor, admins); + // Delete the login account only. Preserve profiles, prescriptions, doses and audit events. + patients.unlinkAppUser(id); + professionals.unlinkAppUser(id); + users.delete(target); + users.flush(); + log.info("Administrator {} deleted login account {}; associated profiles preserved", actor.getId(), id); + } + + private AppUser requireUser(UUID id) { + return users.findForUpdate(id).orElseThrow(() -> new IllegalArgumentException("Utilizador não encontrado.")); + } + + private AppUser requireActiveAdmin(List admins, String email) { + return admins.stream().filter(a -> a.isEnabled() && a.getEmail().equals(email)).findFirst() + .orElseThrow(() -> new AccessDeniedException("É necessário um administrador ativo.")); + } + + private void protectAccount(AppUser target, AppUser actor, List admins) { + if (target.getRole() == Role.ADMIN && target.isEnabled() + && admins.stream().filter(AppUser::isEnabled).count() <= 1) { + throw new IllegalArgumentException("Não é possível remover ou desativar o último administrador ativo."); + } + if (target.getId().equals(actor.getId())) { + throw new IllegalArgumentException("Não é possível eliminar ou desativar a sua própria conta."); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/services/AppUserDetailsService.java b/src/main/java/com/voidsoftware/map02/services/AppUserDetailsService.java new file mode 100644 index 0000000..53c6251 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/AppUserDetailsService.java @@ -0,0 +1,25 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.repositories.AppUserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class AppUserDetailsService implements UserDetailsService { + + private final AppUserRepository appUserRepository; + + public AppUserDetailsService(AppUserRepository appUserRepository) { + this.appUserRepository = appUserRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return appUserRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email)); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/AppUserService.java b/src/main/java/com/voidsoftware/map02/services/AppUserService.java new file mode 100644 index 0000000..32bdeba --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/AppUserService.java @@ -0,0 +1,200 @@ +package com.voidsoftware.map02.services; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.inout.SignupRequest; +import com.voidsoftware.map02.repositories.AppUserRepository; +import com.voidsoftware.map02.repositories.PatientRepository; +import com.voidsoftware.map02.repositories.ProfessionalRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class AppUserService { + + private final AppUserRepository appUserRepository; + private final PasswordEncoder passwordEncoder; + + private final RSAPrivateKey privateKey; + private final RSAPublicKey publicKey; + + private final String jwtIssuer; + private final ProfessionalRepository professionalRepository; + private final PatientRepository patientRepository; + + public AppUserService(@Value("${jwt.secret.key}") String jwtSecretKey, + @Value("${jwt.public.key}") String jwtPublicKey, + AppUserRepository appUserRepository, + PasswordEncoder passwordEncoder, + @Value("${jwt.issuer}") String jwtIssuer, ProfessionalRepository professionalRepository, PatientRepository patientRepository + ) throws Exception { + this.appUserRepository = appUserRepository; + this.passwordEncoder = passwordEncoder; + + this.privateKey = this.loadRSAPrivateKey(jwtSecretKey); + this.publicKey = this.loadRSAPublicKey(jwtPublicKey); + this.jwtIssuer = jwtIssuer; + this.professionalRepository = professionalRepository; + this.patientRepository = patientRepository; + } + + public AppUser register(SignupRequest request) { + + + if (appUserRepository.existsByEmail(request.getEmail())) { + + throw new IllegalArgumentException("Email already registered"); + } + + + AppUser user = new AppUser(); + user.setEmail(request.getEmail()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setRole(request.getRole()); + + user = appUserRepository.save(user); + + switch (request.getRole()) { + + case ADMIN: + break; + case PATIENT: + this.checkForPatient(user); + break; + case PROFESSIONAL: + this.checkForProfessional(user); + break; + } + + return user; + } + + private void checkForProfessional(AppUser user) { + + Optional professional = this.professionalRepository.findByEmail(user.getEmail()); + if (professional.isEmpty()) return; + professional.get().setUserId(user.getId()); + + this.professionalRepository.save(professional.get()); + } + + private void checkForPatient(AppUser user) { + + Optional patient = this.patientRepository.findByEmail(user.getEmail()); + if (patient.isEmpty()) return; + patient.get().setUserId(user.getId()); + + this.patientRepository.save(patient.get()); + + } + + /** + * Generates a signed JWT token using RSA-SHA256 algorithm + * + * @param subject The subject (usually user ID or username) + * @param customClaims Additional custom claims to include in the JWT + * @return Signed JWT token as a String + * @throws Exception if there's an error loading the private key + */ + public String generateSignedJWTWithRSA(String subject, Map customClaims) throws Exception { + + + // Create RSA signer + JWSSigner signer = new RSASSASigner(privateKey); + + // Prepare JWT with claims + JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder() + .subject(subject) + .issuer(jwtIssuer) + .issueTime(new Date()); + + // Add custom claims + if (customClaims != null) { + customClaims.forEach(claimsBuilder::claim); + } + + JWTClaimsSet claimsSet = claimsBuilder.build(); + + // Create signed JWT + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID("flux-reader-key") + .build(), + claimsSet + ); + + // Sign the JWT + signedJWT.sign(signer); + + // Serialize to compact form + return signedJWT.serialize(); + } + + private RSAPrivateKey loadRSAPrivateKey(String keyFile) throws Exception { + if (keyFile == null || keyFile.isEmpty()) { + throw new IllegalArgumentException("RSA private key path is not configured"); + } + byte[] keyBytes = Base64.getDecoder().decode(keyFile); + // Read the key file + String key = new String(keyBytes); + + // Remove the header, footer, and newlines + String privateKeyPEM = key + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + // Decode Base64 + byte[] encoded = Base64.getDecoder().decode(privateKeyPEM); + + // Create the private key + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + return (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + } + + + private RSAPublicKey loadRSAPublicKey(String keyFile) throws Exception { + if (keyFile == null || keyFile.isEmpty()) { + throw new IllegalArgumentException("RSA public key is not configured"); + } + byte[] keyBytes = Base64.getDecoder().decode(keyFile); + // Read the key file + String key = new String(keyBytes); + + // Remove the header, footer, and newlines + String publicKeyPEM = key + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + + // Decode Base64 + byte[] encoded = Base64.getDecoder().decode(publicKeyPEM); + + // Create the public key + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encoded); + return (RSAPublicKey) keyFactory.generatePublic(keySpec); + } + +} diff --git a/src/main/java/com/voidsoftware/map02/services/CompanyService.java b/src/main/java/com/voidsoftware/map02/services/CompanyService.java new file mode 100644 index 0000000..bb71f6e --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/CompanyService.java @@ -0,0 +1,41 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Company; +import com.voidsoftware.map02.models.dto.CompanyDTO; +import com.voidsoftware.map02.models.inout.CreateCompanyRequest; +import com.voidsoftware.map02.models.inout.ListOfCompaniesResponse; +import com.voidsoftware.map02.repositories.CompanyRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class CompanyService { + + private final CompanyRepository companyRepository; + private final DataConversionService dataConversionService; + + public CompanyService(CompanyRepository companyRepository, DataConversionService dataConversionService) { + this.companyRepository = companyRepository; + this.dataConversionService = dataConversionService; + } + + public CompanyDTO createCompany(AppUser sessionUser, CreateCompanyRequest request) { + + Company company = new Company(); + company.setName(request.name()); + company.setVatNumber(request.vatNumber()); + company.setContactEmail(request.contactEmail()); + company = companyRepository.save(company); + + return this.dataConversionService.convertToCompanyDTO(company); + } + + public ListOfCompaniesResponse getAllCompanies() { + + return new ListOfCompaniesResponse(companyRepository.findAll().stream() + .map(dataConversionService::convertToCompanyDTO) + .toList()); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/DashboardService.java b/src/main/java/com/voidsoftware/map02/services/DashboardService.java new file mode 100644 index 0000000..290ed99 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/DashboardService.java @@ -0,0 +1,264 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.dto.DoseStateCountDTO; +import com.voidsoftware.map02.models.view.AlertItemView; +import com.voidsoftware.map02.models.view.DashboardViewModel; +import com.voidsoftware.map02.models.view.PatientCardView; +import com.voidsoftware.map02.repositories.MedicationAlertRepository; +import com.voidsoftware.map02.repositories.MedicationDoseRepository; +import com.voidsoftware.map02.repositories.PatientRepository; +import com.voidsoftware.map02.repositories.PrescriptionRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * Builds the view model behind the professional's landing dashboard (patients + adherence + recent activity). + */ +@Slf4j +@Service +public class DashboardService { + + private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + private static final int ALERTS_LIMIT = 4; + + private final PatientRepository patientRepository; + private final PrescriptionRepository prescriptionRepository; + private final MedicationDoseRepository medicationDoseRepository; + private final MedicationAlertRepository medicationAlertRepository; + private final DataConversionService dataConversionService; + + public DashboardService(PatientRepository patientRepository, + PrescriptionRepository prescriptionRepository, + MedicationDoseRepository medicationDoseRepository, + MedicationAlertRepository medicationAlertRepository, + DataConversionService dataConversionService) { + this.patientRepository = patientRepository; + this.prescriptionRepository = prescriptionRepository; + this.medicationDoseRepository = medicationDoseRepository; + this.medicationAlertRepository = medicationAlertRepository; + this.dataConversionService = dataConversionService; + } + + /** + * Draw order: taken (green) first, then skipped, snoozed, and finally still-pending (gray). + */ + private static int ringOrder(DoseTakenState status) { + return switch (status) { + case TAKEN -> 0; + case SKIPPED -> 1; + case SNOOZED -> 2; + case PENDING -> 3; + }; + } + + private static String ringColor(DoseTakenState status) { + return switch (status) { + case TAKEN -> "var(--color-success)"; + case SKIPPED -> "var(--color-danger)"; + case SNOOZED -> "var(--color-warning)"; + case PENDING -> "var(--color-border)"; + }; + } + + private static String formatPercent(double value) { + return String.format(Locale.ROOT, "%.2f", value); + } + + public DashboardViewModel buildDashboard(Professional professional) { + List patients = patientRepository.findByProfessionalId(professional.getId()); + + int adherencePercent = computeAdherencePercent(professional.getId()); + List alerts = buildAlerts(professional.getId()); + List patientCards = patients.stream().map(this::buildPatientCard).toList(); + + String photo = professional.getResourceId() != null + ? dataConversionService.getImageResource(professional.getResourceId()).orElse(null) + : null; + + return new DashboardViewModel( + professional.getName(), + photo, + adherencePercent, + 100 - adherencePercent, + alerts, + patientCards + ); + } + + /** + * Percentage of decided (TAKEN/SKIPPED) doses that were actually TAKEN, across every patient of the professional. + */ + private int computeAdherencePercent(UUID professionalId) { + List decided = medicationDoseRepository.findByProfessionalIdAndStatusIn( + professionalId, List.of(DoseTakenState.TAKEN, DoseTakenState.SKIPPED)); + if (decided.isEmpty()) { + return 0; + } + long taken = decided.stream().filter(d -> d.getStatus() == DoseTakenState.TAKEN).count(); + return Math.round((taken * 100f) / decided.size()); + } + + /** + * Combines recent MISSED_DOSE alerts with recent late/on-time TAKEN doses into one recency-sorted feed. + */ + private List buildAlerts(UUID professionalId) { + record Timed(Instant at, AlertItemView view) { + } + List timed = new ArrayList<>(); + + medicationAlertRepository.findRecentByProfessionalId(professionalId, PageRequest.of(0, ALERTS_LIMIT)) + .forEach(alert -> { + String medName = alert.getMedicationDose() != null + ? alert.getMedicationDose().getPrescription().getMedication().getName() + : "medicação"; + String message = switch (alert.getAlertType()) { + case "MISSED_DOSE" -> "Falhou a toma de " + medName; + case "LOW_STOCK" -> "Stock baixo de " + medName; + default -> alert.getAlertType(); + }; + timed.add(new Timed(alert.getScheduledAt(), new AlertItemView( + alert.getPatient().getId(), + alert.getPatient().getName(), + photoOf(alert.getPatient()), + message, + formatAgo(alert.getScheduledAt()), + "critical" + ))); + }); + + medicationDoseRepository.findRecentTakenByProfessionalId(professionalId, PageRequest.of(0, ALERTS_LIMIT)) + .forEach(dose -> { + long delayMinutes = Duration.between(dose.getScheduledAt(), dose.getTakenAt()).toMinutes(); + String medName = dose.getPrescription().getMedication().getName(); + Patient patient = dose.getPrescription().getPatient(); + String message = delayMinutes > 0 + ? "Tomou " + medName + " com " + delayMinutes + " mins de atraso" + : "Tomou " + medName + " a horas"; + String severity = delayMinutes > 30 ? "warning" : "info"; + timed.add(new Timed(dose.getTakenAt(), new AlertItemView( + patient.getId(), + patient.getName(), + photoOf(patient), + message, + formatAgo(dose.getTakenAt()), + severity + ))); + }); + + return timed.stream() + .sorted(Comparator.comparing(Timed::at).reversed()) + .limit(ALERTS_LIMIT) + .map(Timed::view) + .toList(); + } + + private PatientCardView buildPatientCard(Patient patient) { + ZoneId zone = ZoneId.of(patient.getTimezone()); + Instant startOfDay = LocalDate.now(zone).atStartOfDay(zone).toInstant(); + Instant endOfDay = LocalDate.now(zone).plusDays(1).atStartOfDay(zone).toInstant(); + + long todayDoseCount = medicationDoseRepository.countByPatientIdAndScheduledAtBetween( + patient.getId(), startOfDay, endOfDay); + + String nextDoseTime = medicationDoseRepository + .findUpcomingPendingByPatientId(patient.getId(), Instant.now(), PageRequest.of(0, 1)) + .stream() + .findFirst() + .map(dose -> HOUR_FORMAT.format(dose.getScheduledAt().atZone(zone))) + .orElse(null); + + List statusCounts = medicationDoseRepository + .countByPatientIdAndScheduledAtBetweenGroupByStatus(patient.getId(), startOfDay, endOfDay); + + List medicationTags = prescriptionRepository.findByPatientIdAndStatus(patient.getId(), "ACTIVE") + .stream() + .map(prescription -> prescription.getMedication().getName()) + .distinct() + .limit(3) + .toList(); + + Integer age = patient.getDateOfBirth() != null + ? Period.between(patient.getDateOfBirth(), LocalDate.now()).getYears() + : null; + + return new PatientCardView( + patient.getId(), + patient.getName(), + age, + photoOf(patient), + medicationTags, + todayDoseCount, + nextDoseTime, + buildDoseRingStyle(statusCounts) + ); + } + + /** + * Renders a patient's dose-status breakdown as a CSS conic-gradient, one arc per status present, + * so the ring visual can represent any number/mix of statuses (not just taken vs. missed). + */ + private String buildDoseRingStyle(List statusCounts) { + long total = statusCounts.stream().filter(p -> !p.status().equals(DoseTakenState.PENDING)).mapToLong(DoseStateCountDTO::count).sum(); + if (total == 0) { + return "conic-gradient(var(--color-border) 0% 100%)"; + } + + List ordered = statusCounts.stream() + .sorted(Comparator.comparing(c -> ringOrder(c.status()))) + .toList(); + + StringBuilder css = new StringBuilder("conic-gradient("); + double cursor = 0; + for (int i = 0; i < ordered.size(); i++) { + DoseStateCountDTO count = ordered.get(i); + double end = cursor + (count.count() * 100.0 / total); + css.append(ringColor(count.status())).append(' ') + .append(formatPercent(cursor)).append("% ") + .append(formatPercent(end)).append('%'); + if (i < ordered.size() - 1) { + css.append(", "); + } + cursor = end; + } + return css.append(")").toString(); + } + + /** + * Same view model as the dashboard card, for the single-patient detail page; empty if the + * patient doesn't exist or isn't assigned to this professional. + */ + public Optional buildPatientDetail(UUID patientId, Professional professional) { + return patientRepository.findById(patientId) + .filter(patient -> patient.getProfessional() != null + && patient.getProfessional().getId().equals(professional.getId())) + .map(this::buildPatientCard); + } + + private String photoOf(Patient patient) { + return patient.getResourceId() != null + ? dataConversionService.getImageResource(patient.getResourceId()).orElse(null) + : null; + } + + private String formatAgo(Instant instant) { + Duration duration = Duration.between(instant, Instant.now()); + if (duration.isNegative()) { + return "brevemente"; + } + long minutes = duration.toMinutes(); + if (minutes < 1) return "agora"; + if (minutes < 60) return minutes + " mins"; + long hours = duration.toHours(); + if (hours < 24) return hours + "h"; + return duration.toDays() + "d"; + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/DataConversionService.java b/src/main/java/com/voidsoftware/map02/services/DataConversionService.java new file mode 100644 index 0000000..51d1670 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/DataConversionService.java @@ -0,0 +1,164 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.dto.*; +import com.voidsoftware.map02.repositories.MediaResourceRepository; +import com.voidsoftware.map02.utils.Utils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +public class DataConversionService { + + + private final Path mediaFolder; + + private final MediaResourceRepository mediaResourceRepository; + + public DataConversionService(@Value("${scripts.root.folder}") String fileSystemRoot, + MediaResourceRepository mediaResourceRepository) throws IOException { + + + Path rootFolder = Path.of(fileSystemRoot); + this.mediaResourceRepository = mediaResourceRepository; + + if (!Files.exists(rootFolder)) { + Files.createDirectory(rootFolder); + log.error(fileSystemRoot); + } + this.mediaFolder = rootFolder.resolve("media"); + + if (!Files.exists(mediaFolder)) { + Files.createDirectory(mediaFolder); + log.info(this.mediaFolder.toString()); + } + + + } + + public CompanyDTO convertToCompanyDTO(Company company) { + Optional photo = Optional.empty(); + if (company.getResourceId() != null) { + photo = this.getImageResource(company.getResourceId()); + } + return new CompanyDTO(company.getId(), company.getName(), company.getContactEmail(), photo.orElse(null)); + } + + public MedicationDTO convertToMedicationDTO(Medication medication) { + + Optional photo = Optional.empty(); + if (medication.getResourceId() != null) { + photo = this.getImageResource(medication.getResourceId()); + + } + + return new MedicationDTO(medication.getId(), medication.getName(), medication.getActiveIngredient(), medication.getDosageForm(), + photo.orElse(null)); + } + + public ProfessionalDTO convertToProfessionalDTO(Professional professional) { + + Optional photo = Optional.empty(); + if (professional.getResourceId() != null) { + photo = this.getImageResource(professional.getResourceId()); + + } + + return new ProfessionalDTO(professional.getId(), professional.getEmail(), + professional.getName(), professional.getCompany().getId(), + photo.orElse(null) + + ); + } + + public PatientPeriodDTO convertToPatientPeriodDTO(PatientPeriods patient) { + + return new PatientPeriodDTO(patient.getPeriodType(), patient.getTimeOfDay()); + + } + + + public PatientDTO convertToPatientDTO(Patient patient) { + + Optional photo = Optional.empty(); + if (patient.getResourceId() != null) { + + + photo = this.getImageResource(patient.getResourceId()); + + } + + return new PatientDTO( + patient.getId(), + patient.getName(), + patient.getEmail(), + patient.getPhoneNumber(), + patient.getAddress(), + patient.getDateOfBirth(), + patient.getTimezone(), + patient.getGender(), + patient.getEmergencyContactName(), + patient.getEmergencyContactPhone(), + patient.getNotes(), + patient.getProfessional() != null ? patient.getProfessional().getId() : null, + photo.orElse(null) + ); + } + + + public Optional getImageResource(UUID resourceId) { + try { + Optional resource = this.mediaResourceRepository.findById(resourceId); + + if (resource.isEmpty()) return Optional.empty(); + + Path coverFile = this.mediaFolder.resolve(resource.get().getFilePath()); + if (!Files.exists(coverFile)) { + return Optional.empty(); + } + + + return Optional.of(Base64.getEncoder() + .encodeToString(Utils.readFileToByteArray(coverFile))); + + } catch (IOException e) { + + log.error("", e); + + } + return Optional.empty(); + } + + public Optional saveImageResource(Base64ImageFileDTO base64Image) { + + + MediaResource resource = new MediaResource(); + + try { + byte[] file = Base64.getDecoder().decode(base64Image.getFile()); + resource.setHash(Utils.hashBytes(file)); + resource.setFilePath(String.format("%s.jpg", resource.getHash())); + + Path coverFile = this.mediaFolder.resolve(resource.getFilePath()); + Utils.saveByteArrayToFile(file, coverFile); + return Optional.of(this.mediaResourceRepository.save(resource)); + + + } catch (Exception e) { + log.error("", e); + + } + + return Optional.empty(); + + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/DoseGenerationService.java b/src/main/java/com/voidsoftware/map02/services/DoseGenerationService.java new file mode 100644 index 0000000..4ec6fff --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/DoseGenerationService.java @@ -0,0 +1,382 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.DoseGenerationRun; +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.entities.PrescriptionSchedule; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.repositories.DoseGenerationRunRepository; +import com.voidsoftware.map02.repositories.MedicationDoseRepository; +import com.voidsoftware.map02.repositories.PrescriptionRepository; +import com.voidsoftware.map02.repositories.PrescriptionScheduleRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.*; + +/** + * Expands prescription schedules into individual {@link MedicationDose} rows for a given date window. + * PRN (prn = true / frequencyType = AS_NEEDED) prescriptions are never pre-generated. + */ +@Slf4j +@Service +public class DoseGenerationService { + + private static final Set FIXED_TIME_FREQUENCIES = EnumSet.of( + FrequencyType.FIXED_TIMES, + FrequencyType.ONCE_DAILY, + FrequencyType.TWICE_DAILY, + FrequencyType.THREE_TIMES_DAILY, + FrequencyType.FOUR_TIMES_DAILY + ); + + private final PrescriptionRepository prescriptionRepository; + private final PrescriptionScheduleRepository prescriptionScheduleRepository; + private final MedicationDoseRepository medicationDoseRepository; + private final DoseGenerationRunRepository doseGenerationRunRepository; + + public DoseGenerationService(PrescriptionRepository prescriptionRepository, + PrescriptionScheduleRepository prescriptionScheduleRepository, + MedicationDoseRepository medicationDoseRepository, + DoseGenerationRunRepository doseGenerationRunRepository) { + this.prescriptionRepository = prescriptionRepository; + this.prescriptionScheduleRepository = prescriptionScheduleRepository; + this.medicationDoseRepository = medicationDoseRepository; + this.doseGenerationRunRepository = doseGenerationRunRepository; + + + } + + private static Instant maxInstant(Instant a, Instant b) { + return a.isAfter(b) ? a : b; + } + + private static Instant minInstant(Instant a, Instant b) { + return a.isBefore(b) ? a : b; + } + + + /** + * Generates (and persists) every missing {@link MedicationDose} for the prescription between + * fromDate and toDate (inclusive, in the patient's local timezone). Doses that already exist + * for the same prescription + instant are skipped, so this is safe to re-run. + */ + @Transactional + public List generateDoses(UUID prescriptionId, LocalDate fromDate, LocalDate toDate) { + log.info("Generating doses for prescription {} between {} and {}", prescriptionId, fromDate, toDate); + if (toDate.isBefore(fromDate)) { + log.error("Invalid date range for prescription {}: toDate {} is before fromDate {}", prescriptionId, toDate, fromDate); + throw new IllegalArgumentException("toDate must not be before fromDate"); + } + + Prescription prescription = prescriptionRepository.findById(prescriptionId) + .orElseThrow(() -> { + log.error("Prescription not found: {}", prescriptionId); + return new IllegalArgumentException("Prescription not found: " + prescriptionId); + }); + + if (Boolean.TRUE.equals(prescription.getPrn())) { + log.debug("Prescription {} is PRN — no doses are pre-generated", prescriptionId); + return List.of(); + } + if (!"ACTIVE".equals(prescription.getStatus())) { + log.warn("Refusing to generate doses for prescription {}: status is {}", prescriptionId, prescription.getStatus()); + throw new IllegalStateException("Cannot generate doses for a " + prescription.getStatus() + " prescription"); + } + + ZoneId zone = ZoneId.of(prescription.getPatient().getTimezone()); + + // clamp the requested window to the prescription's own active date range + LocalDate windowStart = fromDate.isAfter(prescription.getStartDate()) ? fromDate : prescription.getStartDate(); + LocalDate windowEnd = prescription.getEndDate() != null && prescription.getEndDate().isBefore(toDate) + ? prescription.getEndDate() : toDate; + if (windowEnd.isBefore(windowStart)) { + log.debug("Prescription {} has no overlap with requested window [{}, {}] after clamping to [{}, {}]", + prescriptionId, fromDate, toDate, prescription.getStartDate(), prescription.getEndDate()); + return List.of(); + } + log.debug("Prescription {} effective generation window clamped to [{}, {}]", prescriptionId, windowStart, windowEnd); + + Instant windowStartInstant = windowStart.atStartOfDay(zone).toInstant(); + Instant windowEndInstant = windowEnd.plusDays(1).atStartOfDay(zone).toInstant(); + + List versions = prescriptionScheduleRepository + .findByPrescriptionIdOrderByVersionAsc(prescriptionId); + log.debug("Found {} schedule version(s) for prescription {}", versions.size(), prescriptionId); + + UUID batchId = UUID.randomUUID(); + List created = new ArrayList<>(); + int skippedExisting = 0; + + for (PrescriptionSchedule version : versions) { + Instant versionFrom = version.getEffectiveFrom(); + Instant versionTo = version.getEffectiveTo() != null ? version.getEffectiveTo() : windowEndInstant; + + Instant rangeStart = maxInstant(versionFrom, windowStartInstant); + Instant rangeEnd = minInstant(versionTo, windowEndInstant); + if (!rangeStart.isBefore(rangeEnd)) { + log.debug("Schedule version {} (v{}) does not overlap requested window; skipping", + version.getId(), version.getVersion()); + continue; // this schedule version does not overlap the requested window + } + + List occurrences = computeOccurrences(version, rangeStart, rangeEnd, zone); + log.debug("Schedule version {} (v{}, frequency={}) produced {} occurrence(s) in range [{}, {}]", + version.getId(), version.getVersion(), version.getFrequencyType(), occurrences.size(), rangeStart, rangeEnd); + for (Instant scheduledAt : occurrences) { + if (medicationDoseRepository.existsByPrescriptionIdAndScheduledAt(prescriptionId, scheduledAt)) { + skippedExisting++; + continue; + } + MedicationDose dose = new MedicationDose(); + dose.setPrescription(prescription); + dose.setScheduleVersion(version); + dose.setScheduledAt(scheduledAt); + // same instant, rendered in the server's local timezone for easier ops/debugging + dose.setScheduledAtServerTime(LocalDateTime.ofInstant(scheduledAt, ZoneId.systemDefault())); + dose.setStatus(DoseTakenState.PENDING); + dose.setGenerationBatchId(batchId); + created.add(dose); + } + } + + if (created.isEmpty()) { + log.info("No new doses to generate for prescription {} (batch {}, {} already existed)", + prescriptionId, batchId, skippedExisting); + return List.of(); + } + + List saved = medicationDoseRepository.saveAll(created); + log.info("Generated {} new dose(s) for prescription {} (batch {}, {} already existed)", + saved.size(), prescriptionId, batchId, skippedExisting); + return saved; + } + + /** + * Runs {@link #generateDoses} for every ACTIVE, non-PRN prescription belonging to every patient. + * Intended to be invoked once a day (scheduling not wired up here); a failure on one prescription + * is logged and does not stop the rest of the sweep. + */ + @Transactional + public List generateDosesForAllPatients(LocalDate fromDate, LocalDate toDate) { + log.info("Starting dose generation sweep for all active prescriptions between {} and {}", fromDate, toDate); + if (toDate.isBefore(fromDate)) { + log.error("Invalid date range for sweep: toDate {} is before fromDate {}", toDate, fromDate); + throw new IllegalArgumentException("toDate must not be before fromDate"); + } + + List activePrescriptions = prescriptionRepository.findByStatus("ACTIVE"); + log.info("Found {} active prescription(s) to evaluate for dose generation", activePrescriptions.size()); + List created = new ArrayList<>(); + int prnSkipped = 0; + int failures = 0; + + for (Prescription prescription : activePrescriptions) { + if (Boolean.TRUE.equals(prescription.getPrn())) { + prnSkipped++; + continue; + } + try { + created.addAll(generateDoses(prescription.getId(), fromDate, toDate)); + } catch (Exception e) { + failures++; + log.error("Dose generation failed for prescription {}", prescription.getId(), e); + } + } + + log.info("Completed dose generation sweep: {} dose(s) created, {} PRN prescription(s) skipped, {} failure(s)", + created.size(), prnSkipped, failures); + return created; + } + + /** + * Fires once a day at midnight (server time) and generates a single day's worth of doses (today) + * for every ACTIVE, non-PRN prescription. Delegates to {@link #runDailyGenerationIfNeeded} so a + * given calendar day is only ever processed once, recorded via {@link DoseGenerationRun}. + */ + @Scheduled(cron = "${dose-generation.daily-cron:0 0 0 * * *}") + public void scheduledDailyDoseGeneration() { + LocalDate today = LocalDate.now(); + log.info("Scheduled daily dose generation triggered for {}", today); + runDailyGenerationIfNeeded(today); + } + + /** + * Runs {@link #generateDosesForAllPatients} for a single day (runDate to runDate) unless a + * {@link DoseGenerationRun} already exists for that date, in which case it is skipped. Safe to + * call multiple times a day and safe under concurrent invocation: the unique constraint on + * {@code run_date} guarantees only one caller wins the race to create the RUNNING marker. + */ + @Transactional + public List runDailyGenerationIfNeeded(LocalDate runDate) { + if (doseGenerationRunRepository.existsByRunDate(runDate)) { + log.info("Daily dose generation already recorded for {}; skipping", runDate); + return List.of(); + } + + DoseGenerationRun run = new DoseGenerationRun(); + run.setRunDate(runDate); + run.setStatus("RUNNING"); + run.setStartedAt(Instant.now()); + try { + run = doseGenerationRunRepository.saveAndFlush(run); + } catch (DataIntegrityViolationException e) { + log.info("Daily dose generation for {} was already claimed by another run; skipping", runDate); + return List.of(); + } + + try { + List created = generateDosesForAllPatients(runDate, runDate); + run.setStatus("COMPLETED"); + run.setDosesCreated(created.size()); + run.setCompletedAt(Instant.now()); + doseGenerationRunRepository.save(run); + log.info("Daily dose generation run for {} completed: {} dose(s) created", runDate, created.size()); + return created; + } catch (RuntimeException e) { + run.setStatus("FAILED"); + run.setCompletedAt(Instant.now()); + doseGenerationRunRepository.save(run); + log.error("Daily dose generation run for {} failed", runDate, e); + throw e; + } + } + + private List computeOccurrences(PrescriptionSchedule schedule, Instant rangeStart, Instant rangeEnd, ZoneId zone) { + FrequencyType freq = schedule.getFrequencyType(); + log.debug("Computing occurrences for schedule {} with frequency {} in range [{}, {}]", schedule.getId(), freq, rangeStart, rangeEnd); + + if (freq == FrequencyType.AS_NEEDED) { + log.debug("Schedule {} is AS_NEEDED; no occurrences generated", schedule.getId()); + return List.of(); + } + + if (freq == FrequencyType.INTERVAL) { + return computeIntervalOccurrences(schedule, rangeStart, rangeEnd, zone); + } + + if (FIXED_TIME_FREQUENCIES.contains(freq)) { + return computeFixedTimeOccurrences(schedule, rangeStart, rangeEnd, zone, 1); + } + + if (freq == FrequencyType.ALTERNATE_DAYS) { + return computeFixedTimeOccurrences(schedule, rangeStart, rangeEnd, zone, 2); + } + + if (freq == FrequencyType.WEEKLY) { + // Weekday selection is not yet modelled on PrescriptionSchedule; skip until that field exists. + log.warn("Schedule {} uses WEEKLY frequency, which is not yet supported by dose generation", schedule.getId()); + return List.of(); + } + + log.warn("Unhandled frequency type {} on schedule {}", freq, schedule.getId()); + return List.of(); + } + + /** + * Fixed clock-time frequencies (and ALTERNATE_DAYS, via dayStep = 2), driven by schedule.timesOfDay. + */ + private List computeFixedTimeOccurrences(PrescriptionSchedule schedule, Instant rangeStart, Instant rangeEnd, + ZoneId zone, int dayStep) { + List timesOfDay = schedule.getTimesOfDay(); + if (timesOfDay == null || timesOfDay.isEmpty()) { + log.error("Schedule {} has no timesOfDay configured for frequency {}", schedule.getId(), schedule.getFrequencyType()); + throw new IllegalStateException("Schedule " + schedule.getId() + " has no timesOfDay configured for " + schedule.getFrequencyType()); + } + + List result = new ArrayList<>(); + LocalDate anchorDate = schedule.getEffectiveFrom().atZone(zone).toLocalDate(); + LocalDate day = rangeStart.atZone(zone).toLocalDate(); + LocalDate lastDay = rangeEnd.atZone(zone).toLocalDate(); + + while (!day.isAfter(lastDay)) { + boolean onCadence = dayStep <= 1 || (java.time.temporal.ChronoUnit.DAYS.between(anchorDate, day) % dayStep == 0); + if (onCadence) { + for (String timeStr : timesOfDay) { + LocalTime time = LocalTime.parse(timeStr); + Instant scheduledAt = ZonedDateTime.of(day, time, zone).toInstant(); + if (!scheduledAt.isBefore(rangeStart) && scheduledAt.isBefore(rangeEnd)) { + result.add(scheduledAt); + } + } + } + day = day.plusDays(1); + } + return result; + } + + /** + * INTERVAL frequency: doses recur every N hours/days from the schedule's effectiveFrom instant. + * "hours" steps are a fixed physical duration; "days" steps stay anchored to the patient's + * local wall-clock time (via {@code zone}) so they don't drift across DST transitions. + */ + private List computeIntervalOccurrences(PrescriptionSchedule schedule, Instant rangeStart, Instant rangeEnd, ZoneId zone) { + Integer intervalValue = schedule.getIntervalValue(); + String intervalUnit = schedule.getIntervalUnit(); + if (intervalValue == null || intervalValue < 1 || intervalUnit == null) { + log.error("Schedule {} is missing intervalValue/intervalUnit (value={}, unit={})", schedule.getId(), intervalValue, intervalUnit); + throw new IllegalStateException("Schedule " + schedule.getId() + " is missing intervalValue/intervalUnit"); + } + + List result = new ArrayList<>(); + + if ("hours".equalsIgnoreCase(intervalUnit)) { + java.time.Duration step = java.time.Duration.ofHours(intervalValue); + Instant anchor = schedule.getEffectiveFrom(); + + // fast-forward to the first occurrence within [rangeStart, rangeEnd) + Instant occurrence = anchor; + if (occurrence.isBefore(rangeStart)) { + long stepsToSkip = java.time.Duration.between(anchor, rangeStart).dividedBy(step); + occurrence = anchor.plus(step.multipliedBy(stepsToSkip)); + while (occurrence.isBefore(rangeStart)) { + occurrence = occurrence.plus(step); + } + } + + while (occurrence.isBefore(rangeEnd)) { + result.add(occurrence); + occurrence = occurrence.plus(step); + } + return result; + } + + // "days" — step in the patient's local calendar so the wall-clock time-of-day is preserved across DST + ZonedDateTime anchorZoned = schedule.getEffectiveFrom().atZone(zone); + ZonedDateTime occurrenceZoned = anchorZoned; + if (occurrenceZoned.toInstant().isBefore(rangeStart)) { + long daysToSkip = java.time.temporal.ChronoUnit.DAYS.between(anchorZoned, rangeStart.atZone(zone)); + long stepsToSkip = daysToSkip / intervalValue; + occurrenceZoned = anchorZoned.plusDays(stepsToSkip * intervalValue); + while (occurrenceZoned.toInstant().isBefore(rangeStart)) { + occurrenceZoned = occurrenceZoned.plusDays(intervalValue); + } + } + + while (occurrenceZoned.toInstant().isBefore(rangeEnd)) { + result.add(occurrenceZoned.toInstant()); + occurrenceZoned = occurrenceZoned.plusDays(intervalValue); + } + return result; + } + + + /** + * Marks any still-PENDING dose whose server-local scheduled time has passed as SKIPPED. + */ + @Scheduled(fixedRate = 10 * 60 * 1000) + @Transactional + public void expirePendingDoses() { + var pendingMedications = medicationDoseRepository.findByStatusAndScheduledAtServerTimeBefore(DoseTakenState.PENDING, LocalDateTime.now()); + pendingMedications.stream().forEach( + d -> d.setStatus(DoseTakenState.SKIPPED) + ); + + this.medicationDoseRepository.saveAll(pendingMedications); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/DoseSimulationService.java b/src/main/java/com/voidsoftware/map02/services/DoseSimulationService.java new file mode 100644 index 0000000..37e66a7 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/DoseSimulationService.java @@ -0,0 +1,85 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.repositories.MedicationDoseRepository; +import lombok.extern.slf4j.Slf4j; +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.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +/** + * Periodically marks a random sample of currently-due, still-PENDING doses as TAKEN so that + * demo/staging environments show realistic ongoing adherence activity without a real user + * interacting with the app. Disabled by default in production via the + * {@code simulation.dose-taking.enabled} property. + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "simulation.dose-taking.enabled", havingValue = "true") +public class DoseSimulationService { + + private final MedicationDoseRepository medicationDoseRepository; + private final DoseTrackingService doseTrackingService; + private final Random random = new Random(); + + @Value("${simulation.dose-taking.sample-size:5}") + private int sampleSize; + + @Value("${simulation.dose-taking.take-probability:0.7}") + private double takeProbability; + + public DoseSimulationService(MedicationDoseRepository medicationDoseRepository, + DoseTrackingService doseTrackingService) { + this.medicationDoseRepository = medicationDoseRepository; + this.doseTrackingService = doseTrackingService; + } + + /** + * Picks up to {@code sampleSize} random currently-due PENDING doses and, for each, flips a coin + * (weighted by {@code takeProbability}) to decide whether to mark it as taken. Runs on a fixed + * delay so the previous sweep always finishes before the next one starts. + */ + @Scheduled(fixedDelayString = "${simulation.dose-taking.interval-ms:60000}", + initialDelayString = "${simulation.dose-taking.initial-delay-ms:60000}") + public void simulateRandomDoseTaking() { + Instant now = Instant.now(); + List duePending = medicationDoseRepository + .findByStatusAndScheduledAtBefore(DoseTakenState.PENDING, now); + + if (duePending.isEmpty()) { + log.debug("Dose-taking simulation: no due PENDING doses found at {}", now); + return; + } + + List candidates = new ArrayList<>(duePending); + Collections.shuffle(candidates, random); + int sampleCount = Math.min(sampleSize, candidates.size()); + List sample = candidates.subList(0, sampleCount); + + log.info("Dose-taking simulation: sampled {} of {} due PENDING dose(s)", sampleCount, duePending.size()); + + int takenCount = 0; + for (MedicationDose dose : sample) { + if (random.nextDouble() > takeProbability) { + continue; + } + try { + doseTrackingService.recordTaken(dose.getId(), null, Instant.now()); + takenCount++; + log.debug("Dose-taking simulation: marked dose {} as TAKEN", dose.getId()); + } catch (Exception e) { + log.warn("Dose-taking simulation: failed to mark dose {} as taken", dose.getId(), e); + } + } + + log.info("Dose-taking simulation: marked {} dose(s) as TAKEN out of {} sampled", takenCount, sampleCount); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/DoseTrackingService.java b/src/main/java/com/voidsoftware/map02/services/DoseTrackingService.java new file mode 100644 index 0000000..90b858c --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/DoseTrackingService.java @@ -0,0 +1,153 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.repositories.DoseEventRepository; +import com.voidsoftware.map02.repositories.MedicationAlertRepository; +import com.voidsoftware.map02.repositories.MedicationDoseRepository; +import com.voidsoftware.map02.repositories.MedicationInventoryRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Records what actually happened to a generated {@link MedicationDose} (taken / skipped / snoozed) + * and keeps inventory + alerts consistent with that outcome. + */ +@Slf4j +@Service +public class DoseTrackingService { + + private final MedicationDoseRepository medicationDoseRepository; + private final DoseEventRepository doseEventRepository; + private final MedicationInventoryRepository medicationInventoryRepository; + private final MedicationAlertRepository medicationAlertRepository; + + public DoseTrackingService(MedicationDoseRepository medicationDoseRepository, + DoseEventRepository doseEventRepository, + MedicationInventoryRepository medicationInventoryRepository, + MedicationAlertRepository medicationAlertRepository) { + this.medicationDoseRepository = medicationDoseRepository; + this.doseEventRepository = doseEventRepository; + this.medicationInventoryRepository = medicationInventoryRepository; + this.medicationAlertRepository = medicationAlertRepository; + } + + @Transactional + public MedicationDose recordTaken(UUID doseId, AppUser actor, Instant occurredAt) { + MedicationDose dose = requirePending(doseId); + Instant when = occurredAt != null ? occurredAt : Instant.now(); + + dose.setStatus(DoseTakenState.TAKEN); + dose.setTakenAt(when); + medicationDoseRepository.save(dose); + + saveEvent(dose, "TAKEN", when, actor, null); + decrementInventoryAndCheckStock(dose); + + return dose; + } + + @Transactional + public MedicationDose recordSkipped(UUID doseId, AppUser actor, Instant occurredAt, String reason) { + MedicationDose dose = requirePending(doseId); + Instant when = occurredAt != null ? occurredAt : Instant.now(); + + dose.setStatus(DoseTakenState.SKIPPED); + medicationDoseRepository.save(dose); + + Map metadata = reason != null ? Map.of("reason", reason) : null; + saveEvent(dose, "SKIPPED", when, actor, metadata); + + return dose; + } + + @Transactional + public MedicationDose recordSnoozed(UUID doseId, AppUser actor, Instant occurredAt, int snoozeMinutes) { + if (snoozeMinutes < 1) { + throw new IllegalArgumentException("snoozeMinutes must be >= 1"); + } + MedicationDose dose = requirePending(doseId); + Instant when = occurredAt != null ? occurredAt : Instant.now(); + Instant snoozedUntil = when.plusSeconds(snoozeMinutes * 60L); + + dose.setStatus(DoseTakenState.SNOOZED); + dose.setScheduledAt(snoozedUntil); + medicationDoseRepository.save(dose); + + saveEvent(dose, "SNOOZED", when, actor, Map.of("snoozed_until", snoozedUntil.toString())); + + return dose; + } + + /** + * Flags still-PENDING doses whose scheduled time is older than the given cutoff as missed and + * raises a MISSED_DOSE alert for each. Not wired to a scheduler yet — intended to be invoked by + * a future periodic job. + */ + @Transactional + public List sweepMissedDoses(Instant cutoff) { + List overdue = medicationDoseRepository.findByStatusAndScheduledAtBefore(DoseTakenState.PENDING, cutoff); + for (MedicationDose dose : overdue) { + if (medicationAlertRepository.existsByMedicationDoseIdAndAlertType(dose.getId(), "MISSED_DOSE")) { + continue; + } + MedicationAlert alert = new MedicationAlert(); + alert.setPatient(dose.getPrescription().getPatient()); + alert.setMedicationDose(dose); + alert.setAlertType("MISSED_DOSE"); + alert.setScheduledAt(Instant.now()); + medicationAlertRepository.save(alert); + } + return overdue; + } + + private MedicationDose requirePending(UUID doseId) { + MedicationDose dose = medicationDoseRepository.findById(doseId) + .orElseThrow(() -> new IllegalArgumentException("Dose not found: " + doseId)); + if (dose.getStatus() != DoseTakenState.PENDING) { + throw new IllegalStateException("Dose " + doseId + " is already " + dose.getStatus()); + } + return dose; + } + + private void saveEvent(MedicationDose dose, String eventType, Instant occurredAt, AppUser actor, Map metadata) { + DoseEvent event = new DoseEvent(); + event.setDose(dose); + event.setEventType(eventType); + event.setOccurredAt(occurredAt); + event.setActor(actor != null ? actor.getId().toString() : "system"); + event.setMetadata(metadata); + doseEventRepository.save(event); + } + + private void decrementInventoryAndCheckStock(MedicationDose dose) { + PrescriptionSchedule scheduleVersion = dose.getScheduleVersion(); + if (scheduleVersion == null || scheduleVersion.getDoseAmount() == null) { + return; + } + medicationInventoryRepository.findByPrescriptionId(dose.getPrescription().getId()).ifPresent(inventory -> { + BigDecimal remaining = inventory.getQuantityRemaining().subtract(scheduleVersion.getDoseAmount()); + inventory.setQuantityRemaining(remaining); + medicationInventoryRepository.save(inventory); + + if (inventory.getRefillThreshold() != null && remaining.compareTo(inventory.getRefillThreshold()) <= 0) { + boolean alreadyAlerted = medicationAlertRepository + .existsByPatientIdAndAlertTypeAndStatus(dose.getPrescription().getPatient().getId(), "LOW_STOCK", "PENDING"); + if (!alreadyAlerted) { + MedicationAlert alert = new MedicationAlert(); + alert.setPatient(dose.getPrescription().getPatient()); + alert.setAlertType("LOW_STOCK"); + alert.setScheduledAt(Instant.now()); + medicationAlertRepository.save(alert); + } + } + }); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/MedicationService.java b/src/main/java/com/voidsoftware/map02/services/MedicationService.java new file mode 100644 index 0000000..897116e --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/MedicationService.java @@ -0,0 +1,72 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Medication; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.dto.MedicationDTO; +import com.voidsoftware.map02.models.inout.CreateMedicationRequest; +import com.voidsoftware.map02.models.inout.ListOfMedicationsResponse; +import com.voidsoftware.map02.repositories.MedicationRepository; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; + +@Service +public class MedicationService { + + + private final MedicationRepository medicationRepository; + private final DataConversionService dataConversionService; + private final SeedDataService seedDataService; + private final PermissionsValidatorService validatorService; + + public MedicationService(MedicationRepository medicationRepository, + DataConversionService dataConversionService, SeedDataService seedDataService, PermissionsValidatorService validatorService) { + this.medicationRepository = medicationRepository; + this.dataConversionService = dataConversionService; + this.seedDataService = seedDataService; + this.validatorService = validatorService; + + } + + public MedicationDTO createMedication(AppUser sessionUser, + CreateMedicationRequest request) throws IllegalAccessException { + Optional professional = Optional.empty(); + if (!sessionUser.getRole().equals(Role.ADMIN)) { + + professional = this.validatorService.validateProfessional(sessionUser); + + if (professional.isEmpty()) { + + throw new IllegalAccessException("Invalida user type"); + } + } + + Medication med = new Medication(); + med.setName(request.name()); + med.setStrength(request.strength()); + med.setActiveIngredient(request.activeIngredient()); + med.setCreationUser(sessionUser.getId()); + med.setCreatedAt(Instant.now()); + if (professional.isPresent()) { + + med.setCompanyId(professional.get().getCompany().getId()); + } + + this.medicationRepository.save(med); + return this.dataConversionService.convertToMedicationDTO(med); + } + + + public ListOfMedicationsResponse getAllMedications(AppUser sessionUser) { + + return new ListOfMedicationsResponse( + this.medicationRepository.findAll().stream() + .map(this.dataConversionService::convertToMedicationDTO) + .toList() + ); + + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java b/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java new file mode 100644 index 0000000..02d4a2f --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/PatientDetailService.java @@ -0,0 +1,166 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.dto.PatientDoseHistoryDTO; +import com.voidsoftware.map02.models.view.PatientDetailView; +import com.voidsoftware.map02.repositories.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.*; +import java.util.stream.Collectors; + +/** Read-only detail page. All date boundaries and displayed times use the patient's timezone. */ +@Service +public class PatientDetailService { + private static final Locale PORTUGUESE = Locale.forLanguageTag("pt-PT"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + private static final String[] WEEKDAYS = {"SEG", "TER", "QUA", "QUI", "SEX", "SÁB", "DOM"}; + + private final PatientRepository patients; + private final MedicationDoseRepository doses; + private final PrescriptionRepository prescriptions; + private final PrescriptionScheduleRepository schedules; + private final MedicationInventoryRepository inventories; + private final DataConversionService images; + + public PatientDetailService(PatientRepository patients, MedicationDoseRepository doses, + PrescriptionRepository prescriptions, PrescriptionScheduleRepository schedules, + MedicationInventoryRepository inventories, DataConversionService images) { + this.patients = patients; + this.doses = doses; + this.prescriptions = prescriptions; + this.schedules = schedules; + this.inventories = inventories; + this.images = images; + } + + @Transactional(readOnly = true) + public Optional build(UUID patientId, Professional professional, + String mode, LocalDate selectedDate) { + // Authorize before querying any history, stock or media. + return patients.findById(patientId) + .filter(p -> p.getProfessional() != null + && p.getProfessional().getId().equals(professional.getId())) + .map(p -> buildAuthorized(p, "monthly".equals(mode) ? "monthly" : "weekly", selectedDate)); + } + + private PatientDetailView buildAuthorized(Patient patient, String mode, LocalDate selectedDate) { + ZoneId zone = ZoneId.of(patient.getTimezone()); + LocalDate today = LocalDate.now(zone); + LocalDate selected = selectedDate == null ? today : selectedDate; + boolean monthly = "monthly".equals(mode); + LocalDate start = monthly ? selected.withDayOfMonth(1) + : selected.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate end = monthly ? start.plusMonths(1) : start.plusWeeks(1); + List history = doses.findPatientHistory(patient.getId(), + start.atStartOfDay(zone).toInstant(), end.atStartOfDay(zone).toInstant()); + Map> byDay = history.stream() + .collect(Collectors.groupingBy(d -> d.scheduledAt().atZone(zone).toLocalDate())); + List days = start.datesUntil(end) + .map(day -> summarizeDay(day, today, byDay.getOrDefault(day, List.of()))).toList(); + long taken = history.stream().filter(d -> d.status() == DoseTakenState.TAKEN).count(); + List items = buildPrescriptions(patient.getId()); + String name = patient.getName(); + String month = start.format(DateTimeFormatter.ofPattern("MMMM yyyy", PORTUGUESE)); + String label = monthly ? Character.toUpperCase(month.charAt(0)) + month.substring(1) + : start.format(DateTimeFormatter.ofPattern("dd MMM", PORTUGUESE)) + " a " + + end.minusDays(1).format(DateTimeFormatter.ofPattern("dd MMM yyyy", PORTUGUESE)); + List links = new ArrayList<>(); + for (int offset = -1; offset <= 2; offset++) { + links.add(monthly ? start.plusMonths(offset) : start.plusWeeks(offset)); + } + return new PatientDetailView(patient.getId(), name, + name == null || name.isBlank() ? "?" : name.substring(0, 1).toUpperCase(PORTUGUESE), + patient.getDateOfBirth() == null ? null : Period.between(patient.getDateOfBirth(), today).getYears(), + photo(patient.getResourceId()), patient.getPhoneNumber(), zone.getId(), + items.stream().map(PatientDetailView.PrescriptionItem::getName).distinct().limit(3).toList(), + mode, selected, label, monthly ? start.minusMonths(1) : start.minusWeeks(1), + end, links, monthly ? start.getDayOfWeek().getValue() - 1 : 0, + percent(taken, history.size()), taken, history.size(), days, + history.stream().map(d -> new PatientDetailView.Dose( + DATE.format(d.scheduledAt().atZone(zone)), TIME.format(d.scheduledAt().atZone(zone)), + d.medicationName(), statusLabel(d.status()), statusTone(d.status()), + d.takenAt() == null ? "—" : DateTimeFormatter.ofPattern("dd/MM HH:mm") + .format(d.takenAt().atZone(zone)))).toList(), items); + } + + private PatientDetailView.Day summarizeDay(LocalDate date, LocalDate today, List history) { + long taken = history.stream().filter(d -> d.status() == DoseTakenState.TAKEN).count(); + int percent = percent(taken, history.size()); + String tone = history.isEmpty() || (taken == 0 && history.stream().allMatch(d -> d.status() == DoseTakenState.PENDING)) + ? "neutral" : percent >= 80 ? "good" : percent >= 50 ? "warning" : "bad"; + String label = DATE.format(date) + ": " + (history.isEmpty() ? "Sem tomas previstas" + : taken + " de " + history.size() + " tomas realizadas (" + percent + "%)"); + return new PatientDetailView.Day(date, date.format(DateTimeFormatter.ofPattern("dd")), + WEEKDAYS[date.getDayOfWeek().getValue() - 1], history.size(), taken, percent, tone, label, date.equals(today)); + } + + private List buildPrescriptions(UUID patientId) { + List active = prescriptions.findByPatientIdAndStatus(patientId, "ACTIVE"); + if (active.isEmpty()) return List.of(); + List ids = active.stream().map(Prescription::getId).toList(); + Map current = new HashMap<>(); + schedules.findEffectiveForPrescriptions(ids, Instant.now()) + .forEach(s -> current.putIfAbsent(s.getPrescription().getId(), s)); + Map stock = new HashMap<>(); + inventories.findByPrescriptionIdIn(ids).forEach(i -> stock.putIfAbsent(i.getPrescription().getId(), i)); + return active.stream().sorted(Comparator.comparing(p -> p.getMedication().getName(), String.CASE_INSENSITIVE_ORDER)) + .map(p -> { + Medication medication = p.getMedication(); + PrescriptionSchedule schedule = current.get(p.getId()); + MedicationInventory inventory = stock.get(p.getId()); + BigDecimal remaining = inventory == null ? null : inventory.getQuantityRemaining(); + boolean low = remaining != null && (remaining.signum() <= 0 || + (inventory.getRefillThreshold() != null && remaining.compareTo(inventory.getRefillThreshold()) <= 0)); + boolean asNeeded = Boolean.TRUE.equals(p.getPrn()) || + (schedule != null && schedule.getFrequencyType() == FrequencyType.AS_NEEDED); + List times = asNeeded || schedule == null || schedule.getTimesOfDay() == null + ? List.of() : schedule.getTimesOfDay().stream().sorted().distinct().toList(); + String amount = schedule == null || schedule.getDoseAmount() == null ? "—" + : number(schedule.getDoseAmount()) + (schedule.getDoseUnit() == null ? "" : " " + schedule.getDoseUnit()); + return new PatientDetailView.PrescriptionItem(medication.getName(), medication.getStrength(), + photo(medication.getResourceId()), amount, times, frequency(schedule, asNeeded), + remaining == null ? "Stock não registado" : number(remaining) + " em stock", + remaining == null ? "neutral" : low ? "bad" : "good", p.getInstructions()); + }).toList(); + } + + private String frequency(PrescriptionSchedule schedule, boolean asNeeded) { + if (asNeeded) return "Conforme necessário"; + if (schedule == null || schedule.getFrequencyType() == null) return "Horário não configurado"; + return switch (schedule.getFrequencyType()) { + case INTERVAL -> "A cada " + schedule.getIntervalValue() + ("hours".equalsIgnoreCase(schedule.getIntervalUnit()) ? " horas" : " dias"); + case ALTERNATE_DAYS -> "Dias alternados"; + case WEEKLY -> "Semanalmente"; + default -> "Diariamente"; + }; + } + + private String photo(UUID id) { return id == null ? null : images.getImageResource(id).orElse(null); } + private String number(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); } + private int percent(long taken, long total) { return total == 0 ? 0 : (int) Math.round(taken * 100.0 / total); } + private String statusLabel(DoseTakenState status) { + return switch (status) { + case TAKEN -> "Tomada"; + case SKIPPED -> "Não tomada"; + case SNOOZED -> "Adiada"; + case PENDING -> "Pendente"; + }; + } + private String statusTone(DoseTakenState status) { + return switch (status) { + case TAKEN -> "good"; + case SKIPPED -> "bad"; + case SNOOZED -> "warning"; + case PENDING -> "neutral"; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/voidsoftware/map02/services/PatientsService.java b/src/main/java/com/voidsoftware/map02/services/PatientsService.java new file mode 100644 index 0000000..9fc3e18 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/PatientsService.java @@ -0,0 +1,150 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.config.CurrentUser; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.dto.PatientDTO; +import com.voidsoftware.map02.models.inout.ChangeMediaEntityImageRequest; +import com.voidsoftware.map02.models.inout.CreatePatientRequest; +import com.voidsoftware.map02.models.inout.ListOfPatientPeriods; +import com.voidsoftware.map02.models.inout.ListPatientsRequest; +import com.voidsoftware.map02.repositories.PatientPeriodsRepository; +import com.voidsoftware.map02.repositories.PatientRepository; +import com.voidsoftware.map02.repositories.ProfessionalRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +public class PatientsService { + + + private final PatientRepository patientRepository; + private final ProfessionalRepository professionalRepository; + + private final PatientPeriodsRepository patientPeriodsRepository; + + private final DataConversionService dataConversionService; + + public PatientsService(PatientRepository patientRepository, + ProfessionalRepository professionalRepository, PatientPeriodsRepository patientPeriodsRepository, + DataConversionService dataConversionService) { + this.patientRepository = patientRepository; + this.professionalRepository = professionalRepository; + this.patientPeriodsRepository = patientPeriodsRepository; + + + this.dataConversionService = dataConversionService; + } + + public boolean updatePatientPhoto(@CurrentUser AppUser sessionUser, + ChangeMediaEntityImageRequest request) { + + Optional patient = this.patientRepository.findById(request.entityId()); + if (patient.isEmpty()) { + + return false; + } + + + if (request.patientPhoto() == null) { + + return false; + + } + this.dataConversionService.saveImageResource(request.patientPhoto()) + .ifPresent(resource -> { + patient.get().setResourceId(resource.getId()); + this.patientRepository.save(patient.get()); + }); + + return true; + + } + + public PatientDTO createPatient(AppUser sessionUser, + CreatePatientRequest request) { + Professional professional = professionalRepository.findById(request.getProfessionalId()) + .orElseThrow(() -> new IllegalArgumentException("Professional not found")); + + Patient patient = new Patient(); + patient.setName(request.getName()); + patient.setDateOfBirth(request.getDateOfBirth()); + patient.setTimezone(request.getTimezone()); + patient.setActive(request.getActive() != null ? request.getActive() : true); + patient.setProfessional(professional); + + if (request.getPatientPhoto() != null) { + + this.dataConversionService.saveImageResource(request.getPatientPhoto()) + .ifPresent(resource -> patient.setResourceId(resource.getId())); + } + + + return this.dataConversionService.convertToPatientDTO(patientRepository.save(patient)); + } + + public PatientDTO getPatientById(AppUser sessionUser, UUID patientId) { + Optional patient = patientRepository.findById(patientId); + + if (patient.isEmpty()) { + throw new IllegalArgumentException("Patient not found"); + } + return this.dataConversionService.convertToPatientDTO(patient.get()); + } + + public List getPatientsListByProfessional(AppUser sessionUser, ListPatientsRequest request) { + + if (sessionUser.getRole().equals(Role.PATIENT)) { + throw new IllegalArgumentException("Invalid user for query"); + } + + if (!sessionUser.getRole().equals(Role.ADMIN)) { + + + if (sessionUser.getEntityId() == null) { + + throw new IllegalArgumentException("Invalid user for query"); + } + + Optional professional = professionalRepository.findById(request.getProfessionalId()); + + if (professional.isEmpty()) { + throw new IllegalArgumentException("Professional not found: " + request.getProfessionalId()); + } + + if (!sessionUser.getEntityId().equals(professional.get().getId())) { + throw new IllegalArgumentException("Invalid Professional " + request.getProfessionalId()); + } + } + return patientRepository.findByProfessionalId(request.getProfessionalId()) + .stream().map(this.dataConversionService::convertToPatientDTO) + .toList(); + } + + public ListOfPatientPeriods getPatientMedicationDayPeriods(AppUser sessionUser) { + + if (sessionUser.getRole().equals(Role.PATIENT) && sessionUser.getEntityId() == null) { + + throw new IllegalArgumentException("Invalid user"); + } + + return getPatientMedicationDayPeriods(sessionUser.getEntityId()); + + } + + public ListOfPatientPeriods getPatientMedicationDayPeriods(UUID patientId) { + + + return new ListOfPatientPeriods(this.patientPeriodsRepository.findByPatientId(patientId) + .stream().map(this.dataConversionService::convertToPatientPeriodDTO) + .toList()); + + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/PermissionsValidatorService.java b/src/main/java/com/voidsoftware/map02/services/PermissionsValidatorService.java new file mode 100644 index 0000000..ff147de --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/PermissionsValidatorService.java @@ -0,0 +1,83 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.repositories.AppUserRepository; +import com.voidsoftware.map02.repositories.PatientRepository; +import com.voidsoftware.map02.repositories.ProfessionalRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class PermissionsValidatorService { + + private final PatientRepository patientRepository; + + private final ProfessionalRepository professionalRepository; + private final AppUserRepository appUserRepository; + private final ConcurrentHashMap patientsCache = new ConcurrentHashMap<>(); + + public PermissionsValidatorService(PatientRepository patientRepository, ProfessionalRepository professionalRepository, AppUserRepository appUserRepository) { + + this.patientRepository = patientRepository; + this.professionalRepository = professionalRepository; + this.appUserRepository = appUserRepository; + } + + public boolean validatePatient(AppUser sessionUser, UUID patientId) { + + if (sessionUser.getRole().equals(Role.PATIENT)) { + + Optional patient = patientRepository.findById(patientId); + if (patient.isEmpty()) { + return false; + } + + this.patientsCache.put(patientId, patient.get()); + + return patient.get().getUserId() != null && + sessionUser.getId().equals(patient.get().getUserId()); + } + + + return false; + } + + public Optional validateProfessional(AppUser sessionUser) { + + if (!sessionUser.getRole().equals(Role.PROFESSIONAL)) { + return Optional.empty(); + } + + if (sessionUser.getEntityId() != null) { + Optional professional = this.professionalRepository.findById(sessionUser.getEntityId()); + return professional; + } + + Optional professional = this.professionalRepository.findByEmail(sessionUser.getEmail()); + + if (professional.isEmpty()) return professional; + + sessionUser.setEntityId(professional.get().getId()); + + this.appUserRepository.save(sessionUser); + professional.get().setUserId(sessionUser.getId()); + + return Optional.of(this.professionalRepository.save(professional.get())); + + } + + public boolean validatePrescrption(AppUser sessionUser, UUID prescriptionId) { + + return false; + + } + +} diff --git a/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java b/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java new file mode 100644 index 0000000..02c28f1 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/PrescriptionsService.java @@ -0,0 +1,242 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.inout.ChangePrescriptionScheduleRequest; +import com.voidsoftware.map02.models.inout.CreatePrescriptionRequest; +import com.voidsoftware.map02.models.inout.ListPrescriptionsRequest; +import com.voidsoftware.map02.models.inout.PrescriptionWithSchedulesResponse; +import com.voidsoftware.map02.repositories.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@Service +public class PrescriptionsService { + + + private static final Set REQUIRES_TIMES = EnumSet.of( + FrequencyType.FIXED_TIMES, + FrequencyType.TWICE_DAILY, + FrequencyType.THREE_TIMES_DAILY, + FrequencyType.FOUR_TIMES_DAILY, + FrequencyType.ONCE_DAILY + ); + + private final PatientRepository patientRepository; + private final ProfessionalRepository professionalRepository; + private final PrescriptionRepository prescriptionRepository; + private final MedicationRepository medicationRepository; + private final PrescriptionScheduleRepository prescriptionScheduleRepository; + + public PrescriptionsService(PatientRepository patientRepository, ProfessionalRepository professionalRepository, PrescriptionRepository prescriptionRepository, MedicationRepository medicationRepository, PrescriptionScheduleRepository prescriptionScheduleRepository) { + this.patientRepository = patientRepository; + this.professionalRepository = professionalRepository; + this.prescriptionRepository = prescriptionRepository; + this.medicationRepository = medicationRepository; + this.prescriptionScheduleRepository = prescriptionScheduleRepository; + } + + + @Transactional + public Prescription createPrescription(AppUser sessionUser, + CreatePrescriptionRequest request) { + + // --- resolve referenced entities --- + Patient patient = patientRepository.findById(request.getPatientId()) + .orElseThrow(() -> new IllegalArgumentException("Patient not found: " + request.getPatientId())); + + Medication medication = medicationRepository.findById(request.getMedicationId()) + .orElseThrow(() -> new IllegalArgumentException("Medication not found: " + request.getMedicationId())); + + Professional prescriber = professionalRepository.findById(request.getPrescriberId()) + .orElseThrow(() -> new IllegalArgumentException("Professional not found: " + request.getPrescriberId())); + + // --- date range validation --- + if (request.getEndDate() != null && !request.getEndDate().isAfter(request.getStartDate())) { + throw new IllegalArgumentException("endDate must be after startDate"); + } + + + FrequencyType freq = request.getFrequencyType(); + + // --- prn / frequency consistency --- + if (request.isPrn() && freq != FrequencyType.AS_NEEDED) { + throw new IllegalArgumentException("PRN prescriptions must use frequencyType AS_NEEDED"); + } + if (!request.isPrn() && freq == FrequencyType.AS_NEEDED) { + throw new IllegalArgumentException("AS_NEEDED frequency requires prn = true"); + } + + // --- INTERVAL requires intervalValue + intervalUnit --- + if (freq == FrequencyType.INTERVAL) { + if (request.getIntervalValue() == null || request.getIntervalValue() < 1) { + throw new IllegalArgumentException("intervalValue is required and must be >= 1 for INTERVAL frequency"); + } + if (request.getIntervalUnit() == null || request.getIntervalUnit().isBlank()) { + throw new IllegalArgumentException("intervalUnit (hours|days) is required for INTERVAL frequency"); + } + String unit = request.getIntervalUnit().toLowerCase(); + if (!unit.equals("hours") && !unit.equals("days")) { + throw new IllegalArgumentException("intervalUnit must be 'hours' or 'days'"); + } + } + + // --- fixed-time frequencies require timesOfDay --- + if (REQUIRES_TIMES.contains(freq)) { + List times = request.getTimesOfDay(); + if (times == null || times.isEmpty()) { + throw new IllegalArgumentException("timesOfDay is required for frequencyType " + freq); + } + validateTimeCount(freq, times); + } + + // --- resolve effective-from in the patient's timezone --- + String tzId = request.getTimezone() != null ? request.getTimezone() : patient.getTimezone(); + ZoneId zone = ZoneId.of(tzId); + + // --- persist Prescription --- + Prescription prescription = new Prescription(); + prescription.setPatient(patient); + prescription.setMedication(medication); + prescription.setProfessional(prescriber); + prescription.setStartDate(request.getStartDate()); + prescription.setEndDate(request.getEndDate()); + prescription.setPrn(request.isPrn()); + prescription.setInstructions(request.getInstructions()); + prescription.setStatus("ACTIVE"); + prescription = prescriptionRepository.save(prescription); + + // --- persist initial PrescriptionSchedule (version 1) --- + PrescriptionSchedule schedule = new PrescriptionSchedule(); + schedule.setPrescription(prescription); + schedule.setVersion(1); + schedule.setFrequencyType(freq); + schedule.setIntervalValue(request.getIntervalValue()); + schedule.setIntervalUnit(request.getIntervalUnit()); + schedule.setTimesOfDay(request.getTimesOfDay() != null ? request.getTimesOfDay() : List.of()); + schedule.setDoseAmount(request.getDoseAmount()); + schedule.setDoseUnit(request.getDoseUnit()); + schedule.setEffectiveFrom(request.getStartDate().atStartOfDay(zone).toInstant()); + schedule.setEffectiveTo(null); + prescriptionScheduleRepository.save(schedule); + + return prescription; + } + + @Transactional + public PrescriptionSchedule changePatientPrescription(AppUser sessionUser, ChangePrescriptionScheduleRequest request) { + + Prescription prescription = prescriptionRepository.findById(request.getPrescriptionId()) + .orElseThrow(() -> new IllegalArgumentException("Prescription not found: " + request.getPrescriptionId())); + + if (!"ACTIVE".equals(prescription.getStatus())) { + throw new IllegalStateException("Cannot change schedule of a " + prescription.getStatus() + " prescription"); + } + + FrequencyType freq = request.getFrequencyType(); + + // --- same schedule-level validations as on creation --- + if (freq == FrequencyType.INTERVAL) { + if (request.getIntervalValue() == null || request.getIntervalValue() < 1) { + throw new IllegalArgumentException("intervalValue is required and must be >= 1 for INTERVAL frequency"); + } + if (request.getIntervalUnit() == null || request.getIntervalUnit().isBlank()) { + throw new IllegalArgumentException("intervalUnit (hours|days) is required for INTERVAL frequency"); + } + String unit = request.getIntervalUnit().toLowerCase(); + if (!unit.equals("hours") && !unit.equals("days")) { + throw new IllegalArgumentException("intervalUnit must be 'hours' or 'days'"); + } + } + if (REQUIRES_TIMES.contains(freq)) { + List times = request.getTimesOfDay(); + if (times == null || times.isEmpty()) { + throw new IllegalArgumentException("timesOfDay is required for frequencyType " + freq); + } + validateTimeCount(freq, times); + } + + // --- effectiveFrom must be in the future --- + String tzId = request.getTimezone() != null + ? request.getTimezone() + : prescription.getPatient().getTimezone(); + ZoneId zone = ZoneId.of(tzId); + Instant effectiveFrom = request.getEffectiveFrom().atStartOfDay(zone).toInstant(); + if (!effectiveFrom.isAfter(Instant.now())) { + throw new IllegalArgumentException("effectiveFrom must be a future date"); + } + + // --- close the currently active schedule version --- + PrescriptionSchedule current = prescriptionScheduleRepository + .findActiveByPrescriptionId(request.getPrescriptionId()) + .orElseThrow(() -> new IllegalStateException("No active schedule found for prescription " + request.getPrescriptionId())); + current.setEffectiveTo(effectiveFrom); + prescriptionScheduleRepository.save(current); + + // --- open the new version --- + int nextVersion = prescriptionScheduleRepository.findMaxVersionByPrescriptionId(request.getPrescriptionId()) + 1; + + PrescriptionSchedule newSchedule = new PrescriptionSchedule(); + newSchedule.setPrescription(prescription); + newSchedule.setVersion(nextVersion); + newSchedule.setFrequencyType(freq); + newSchedule.setIntervalValue(request.getIntervalValue()); + newSchedule.setIntervalUnit(request.getIntervalUnit()); + newSchedule.setTimesOfDay(request.getTimesOfDay() != null ? request.getTimesOfDay() : List.of()); + newSchedule.setDoseAmount(request.getDoseAmount()); + newSchedule.setDoseUnit(request.getDoseUnit()); + newSchedule.setEffectiveFrom(effectiveFrom); + newSchedule.setEffectiveTo(null); + + return prescriptionScheduleRepository.save(newSchedule); + } + + private void validateTimeCount(FrequencyType freq, List times) { + int size = times.size(); + switch (freq) { + case ONCE_DAILY -> { + if (size != 1) throw new IllegalArgumentException("ONCE_DAILY requires exactly 1 time"); + } + case TWICE_DAILY -> { + if (size != 2) throw new IllegalArgumentException("TWICE_DAILY requires exactly 2 times"); + } + case THREE_TIMES_DAILY -> { + if (size != 3) throw new IllegalArgumentException("THREE_TIMES_DAILY requires exactly 3 times"); + } + case FOUR_TIMES_DAILY -> { + if (size != 4) throw new IllegalArgumentException("FOUR_TIMES_DAILY requires exactly 4 times"); + } + default -> { /* FIXED_TIMES accepts any non-empty list */ } + } + } + + public List listPrescriptions(ListPrescriptionsRequest request) { + if (!patientRepository.existsById(request.getPatientId())) { + throw new IllegalArgumentException("Patient not found: " + request.getPatientId()); + } + + List prescriptions = request.isActiveOnly() + ? prescriptionRepository.findByPatientIdAndStatus(request.getPatientId(), "ACTIVE") + : prescriptionRepository.findByPatientId(request.getPatientId()); + + return prescriptions.stream() + .map(p -> { + List schedules = switch (request.getScheduleFilter()) { + case ALL -> prescriptionScheduleRepository + .findByPrescriptionIdOrderByVersionAsc(p.getId()); + case ACTIVE_ONLY -> prescriptionScheduleRepository + .findActiveListByPrescriptionId(p.getId()); + }; + return new PrescriptionWithSchedulesResponse(p, schedules); + }) + .toList(); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/ProfessionalsService.java b/src/main/java/com/voidsoftware/map02/services/ProfessionalsService.java new file mode 100644 index 0000000..b2ff347 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/ProfessionalsService.java @@ -0,0 +1,50 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Company; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.dto.ProfessionalDTO; +import com.voidsoftware.map02.models.inout.CreateProfessionalRequest; +import com.voidsoftware.map02.models.inout.ListOfProfessionalsResponse; +import com.voidsoftware.map02.models.inout.ListProfessionalsRequest; +import com.voidsoftware.map02.repositories.CompanyRepository; +import com.voidsoftware.map02.repositories.ProfessionalRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class ProfessionalsService { + + private final ProfessionalRepository professionalRepository; + private final CompanyRepository companyRepository; + private final DataConversionService dataConversionService; + + public ProfessionalsService(ProfessionalRepository professionalRepository, CompanyRepository companyRepository, DataConversionService dataConversionService) { + this.professionalRepository = professionalRepository; + this.companyRepository = companyRepository; + this.dataConversionService = dataConversionService; + } + + public ProfessionalDTO createProfessional(AppUser sessionUser, + CreateProfessionalRequest request) { + Company company = companyRepository.findById(request.getCompanyId()) + .orElseThrow(() -> new IllegalArgumentException("Company not found")); + + Professional professional = new Professional(); + professional.setName(request.getName()); + professional.setEmail(request.getEmail()); + professional.setLicenseNumber(request.getLicenseNumber()); + professional.setCompany(company); + + return this.dataConversionService.convertToProfessionalDTO(professionalRepository.save(professional)); + } + + public ListOfProfessionalsResponse listByCompany(ListProfessionalsRequest request) { + if (!companyRepository.existsById(request.getCompanyId())) { + throw new IllegalArgumentException("Company not found: " + request.getCompanyId()); + } + return new ListOfProfessionalsResponse(professionalRepository.findByCompanyId(request.getCompanyId()) + .stream().map(this.dataConversionService::convertToProfessionalDTO).toList()); + } +} diff --git a/src/main/java/com/voidsoftware/map02/services/SeedDataService.java b/src/main/java/com/voidsoftware/map02/services/SeedDataService.java new file mode 100644 index 0000000..12aca9b --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/services/SeedDataService.java @@ -0,0 +1,331 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.Company; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.entities.Medication; +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.DosageForm; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.Gender; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.inout.CreatePrescriptionRequest; +import com.voidsoftware.map02.repositories.CompanyRepository; +import com.voidsoftware.map02.repositories.MedicationRepository; +import com.voidsoftware.map02.repositories.PatientRepository; +import com.voidsoftware.map02.repositories.ProfessionalRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +@Slf4j +@Service +public class SeedDataService { + + public static final int MAX_GENERATED_PRESCRIPTIONS = 50; + + private static final String[] FIRST_NAMES = { + "Ana", "Bruno", "Carla", "David", "Elena", "Filipe", "Gabriela", "Hugo", + "Inês", "João", "Katia", "Luís", "Mariana", "Nuno", "Olga", "Pedro", + "Raquel", "Sofia", "Tiago", "Vera", "Alice", "Carlos", "Diana", "Eduardo" + }; + + private static final String[] LAST_NAMES = { + "Silva", "Santos", "Ferreira", "Pereira", "Oliveira", "Costa", "Rodrigues", + "Martins", "Jesus", "Sousa", "Fernandes", "Gonçalves", "Lopes", "Marques", + "Alves", "Almeida", "Ribeiro", "Pinto", "Carvalho", "Teixeira" + }; + + private static final String[] COMPANY_SUFFIXES = { + "Clínica", "Saúde", "Medical", "Health", "Care", "Clinic", "Hospital", "Centro Médico" + }; + + private static final String[] SPECIALTIES_PREFIXES = { + "Cardio", "Neuro", "Orto", "Pedia", "Dermato", "Gastro", "Pulmo", "Endo" + }; + + private static final String[] CITIES = { + "Lisboa", "Porto", "Coimbra", "Braga", "Aveiro", "Setúbal", "Faro", "Évora" + }; + + private static final String[] TIMEZONES = { + "Europe/Lisbon", "Europe/London", "Europe/Madrid", "Atlantic/Azores" + }; + + private static final String[] BLOOD_TYPES = {"A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"}; + + private static final String[] GENDERS = {"MALE", "FEMALE", "OTHER"}; + + private static final String[][] MEDICATIONS = { + {"Metformina", "Metformin", "500mg"}, + {"Lisinopril", "Lisinopril", "10mg"}, + {"Atorvastatina", "Atorvastatin", "20mg"}, + {"Omeprazol", "Omeprazole", "20mg"}, + {"Amoxicilina", "Amoxicillin", "500mg"}, + {"Ibuprofeno", "Ibuprofen", "400mg"}, + {"Paracetamol", "Paracetamol", "1000mg"}, + {"Losartan", "Losartan", "50mg"}, + {"Salbutamol", "Salbutamol", "100mcg"}, + {"Insulina Glargina", "Insulin Glargine", "100U/mL"}, + {"Sertralina", "Sertraline", "50mg"}, + {"Levotiroxina", "Levothyroxine", "50mcg"}, + {"Furosemida", "Furosemide", "40mg"}, + {"Amlodipina", "Amlodipine", "5mg"}, + {"Pantoprazol", "Pantoprazole", "40mg"}, + {"Prednisolona", "Prednisolone", "5mg"}, + {"Azitromicina", "Azithromycin", "500mg"}, + {"Enalapril", "Enalapril", "10mg"}, + {"Diazepam", "Diazepam", "5mg"}, + {"Tramadol", "Tramadol", "50mg"}, + }; + + private static final DosageForm[] DOSAGE_FORMS_BY_MED = { + DosageForm.TABLET, DosageForm.TABLET, DosageForm.TABLET, DosageForm.CAPSULE, + DosageForm.CAPSULE, DosageForm.TABLET, DosageForm.TABLET, DosageForm.TABLET, + DosageForm.INHALER, DosageForm.INJECTION, DosageForm.TABLET, DosageForm.TABLET, + DosageForm.TABLET, DosageForm.TABLET, DosageForm.TABLET, DosageForm.TABLET, + DosageForm.TABLET, DosageForm.TABLET, DosageForm.TABLET, DosageForm.CAPSULE, + }; + + private final CompanyRepository companyRepository; + private final ProfessionalRepository professionalRepository; + private final PatientRepository patientRepository; + private final MedicationRepository medicationRepository; + private final PrescriptionsService prescriptionsService; + + public SeedDataService(CompanyRepository companyRepository, + ProfessionalRepository professionalRepository, + PatientRepository patientRepository, + MedicationRepository medicationRepository, + PrescriptionsService prescriptionsService) { + this.companyRepository = companyRepository; + this.professionalRepository = professionalRepository; + this.patientRepository = patientRepository; + this.medicationRepository = medicationRepository; + this.prescriptionsService = prescriptionsService; + // Seeding is explicit: constructing the bean must not modify the database. + } + + /** Synthetic test data only; the complete batch and its schedules share one transaction. */ + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public List generatePrescriptions(AppUser actor, UUID patientId, int count) { + if (actor == null || actor.getRole() != Role.ADMIN || !actor.isEnabled()) { + throw new AccessDeniedException("É necessário um administrador ativo."); + } + if (count < 1 || count > MAX_GENERATED_PRESCRIPTIONS) { + throw new IllegalArgumentException("Escolha entre 1 e " + MAX_GENERATED_PRESCRIPTIONS + " prescrições."); + } + if (patientId == null) throw new IllegalArgumentException("Selecione um paciente."); + Patient patient = patientRepository.findById(patientId) + .orElseThrow(() -> new IllegalArgumentException("Paciente não encontrado.")); + if (patient.getProfessional() == null) { + throw new IllegalArgumentException("Associe um profissional ao paciente antes de gerar prescrições."); + } + if (patient.getTimezone() == null || patient.getTimezone().isBlank()) { + throw new IllegalArgumentException("Configure um fuso horário válido para o paciente."); + } + LocalDate startDate; + try { + startDate = LocalDate.now(ZoneId.of(patient.getTimezone())); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Configure um fuso horário válido para o paciente.", e); + } + List medications = new ArrayList<>(medicationRepository.findAll()); + if (medications.isEmpty()) { + throw new IllegalArgumentException("Não existem medicamentos. Crie medicamentos antes de gerar prescrições."); + } + + List saved = new ArrayList<>(); + ThreadLocalRandom rng = ThreadLocalRandom.current(); + for (int i = 0; i < count; i++) { + // Use the full catalog before repeating a medication within the same batch. + if (i % medications.size() == 0) Collections.shuffle(medications); + Medication medication = medications.get(i % medications.size()); + int dailyDoses = rng.nextInt(1, 4); + CreatePrescriptionRequest request = new CreatePrescriptionRequest(); + request.setPatientId(patientId); + request.setMedicationId(medication.getId()); + request.setPrescriberId(patient.getProfessional().getId()); + request.setTimezone(patient.getTimezone()); + request.setStartDate(startDate); + request.setEndDate(startDate.plusDays(rng.nextInt(7, 31))); + request.setPrn(false); + request.setFrequencyType(switch (dailyDoses) { + case 1 -> FrequencyType.ONCE_DAILY; + case 2 -> FrequencyType.TWICE_DAILY; + default -> FrequencyType.THREE_TIMES_DAILY; + }); + request.setTimesOfDay(switch (dailyDoses) { + case 1 -> List.of("08:00"); + case 2 -> List.of("08:00", "20:00"); + default -> List.of("08:00", "14:00", "20:00"); + }); + request.setDoseAmount(BigDecimal.ONE); + request.setDoseUnit("unidade de teste"); + request.setInstructions("DADOS DE TESTE — prescrição e horários gerados aleatoriamente. " + + "Não utilizar para decisões clínicas ou administração real de medicamentos."); + saved.add(prescriptionsService.createPrescription(actor, request)); + } + log.info("Administrator {} generated {} test prescriptions for patient {}", actor.getId(), saved.size(), patientId); + return saved; + } + + @Transactional + public List generateCompanies(int count) { + + try { + + List saved = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Company c = new Company(); + String city = pick(CITIES); + String suffix = pick(COMPANY_SUFFIXES); + c.setName(suffix + " " + city + " " + (i + 1)); + c.setVatNumber(randomVat()); + c.setContactEmail("contact" + (i + 1) + "@" + c.getName() + ".pt"); + saved.add(companyRepository.save(c)); + } + return saved; + + } catch (Exception e) { + + log.error("", e); + } + return null; + } + + @Transactional + public List generateProfessionals(int count) { + List companies = companyRepository.findAll(); + if (companies.isEmpty()) { + throw new IllegalStateException("No companies found — generate companies first."); + } + List saved = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Professional p = new Professional(); + p.setName(randomName()); + p.setLicenseNumber("OM-" + randomDigits(6)); + p.setCompany(pick(companies)); + saved.add(professionalRepository.save(p)); + } + return saved; + } + + @Transactional + public List generatePatients(int count) { + List professionals = professionalRepository.findAll(); + if (professionals.isEmpty()) { + throw new IllegalStateException("No professionals found — generate professionals first."); + } + List saved = new ArrayList<>(); + ThreadLocalRandom rng = ThreadLocalRandom.current(); + for (int i = 0; i < count; i++) { + Patient p = new Patient(); + String firstName = pick(FIRST_NAMES); + String lastName = pick(LAST_NAMES); + p.setName(firstName + " " + lastName); + p.setDateOfBirth(randomDob()); + p.setTimezone(pick(TIMEZONES)); + p.setActive(rng.nextInt(10) > 1); // ~80% active + p.setEmail(firstName.toLowerCase() + "." + lastName.toLowerCase() + randomDigits(3) + "@mail.pt"); + p.setPhoneNumber("+351 9" + randomDigits(8)); + p.setGender(Gender.MALE); + p.setAddress(randomDigits(3) + " Rua " + pick(LAST_NAMES) + ", " + pick(CITIES)); + p.setBloodType(pick(BLOOD_TYPES)); + p.setEmergencyContactName(randomName()); + p.setEmergencyContactPhone("+351 9" + randomDigits(8)); + p.setNotes(randomClinicalNote()); + p.setProfessional(pick(professionals)); + saved.add(patientRepository.save(p)); + } + return saved; + } + + @Transactional + public List generateMedications(int count) { + int available = MEDICATIONS.length; + int toGenerate = Math.min(count, available); + List saved = new ArrayList<>(); + // shuffle indices to avoid duplicates when count < available + List indices = new ArrayList<>(); + for (int i = 0; i < available; i++) indices.add(i); + java.util.Collections.shuffle(indices); + for (int i = 0; i < toGenerate; i++) { + int idx = indices.get(i); + Medication m = new Medication(); + m.setName(MEDICATIONS[idx][0]); + m.setActiveIngredient(MEDICATIONS[idx][1]); + m.setStrength(MEDICATIONS[idx][2]); + m.setDosageForm(DOSAGE_FORMS_BY_MED[idx]); + m.setCreatedAt(Instant.now()); + saved.add(medicationRepository.save(m)); + } + return saved; + } + + // --- helpers --- + + private T pick(T[] arr) { + return arr[ThreadLocalRandom.current().nextInt(arr.length)]; + } + + private T pick(List list) { + return list.get(ThreadLocalRandom.current().nextInt(list.size())); + } + + private String randomName() { + return pick(FIRST_NAMES) + " " + pick(LAST_NAMES); + } + + private String randomVat() { + return "PT" + randomDigits(9); + } + + private String randomDigits(int length) { + StringBuilder sb = new StringBuilder(); + ThreadLocalRandom rng = ThreadLocalRandom.current(); + for (int i = 0; i < length; i++) sb.append(rng.nextInt(10)); + return sb.toString(); + } + + private LocalDate randomDob() { + // age between 18 and 85 + int age = ThreadLocalRandom.current().nextInt(18, 85); + return LocalDate.now().minusYears(age).minusDays(ThreadLocalRandom.current().nextInt(365)); + } + + private String slug(String name) { + return name.toLowerCase() + .replaceAll("[^a-z0-9]", "") + .substring(0, Math.min(12, name.length())); + } + + private String randomClinicalNote() { + String[] notes = { + "Patient presents with controlled hypertension under medication.", + "Type 2 diabetes, diet-controlled. Regular follow-up required.", + "Seasonal allergic rhinitis, no known drug allergies.", + "History of asthma, currently stable on inhaled therapy.", + "Post-operative follow-up after knee replacement. Progressing well.", + "Anxiety disorder under pharmacological and psychotherapeutic treatment.", + "Hypercholesterolaemia managed with statins. Lipid profile improving.", + "No significant medical history. Routine preventive care.", + }; + return pick(notes); + } +} diff --git a/src/main/java/com/voidsoftware/map02/utils/Utils.java b/src/main/java/com/voidsoftware/map02/utils/Utils.java new file mode 100644 index 0000000..994a630 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/utils/Utils.java @@ -0,0 +1,181 @@ +package com.voidsoftware.map02.utils; + +import org.springframework.core.io.ClassPathResource; + +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Locale; + +public class Utils { + + + public static DateTimeFormatter monthYearFormatter = DateTimeFormatter.ofPattern("MMM yyyy", Locale.ENGLISH); + public static DateTimeFormatter monthYearDayFormatter = DateTimeFormatter.ofPattern("YYYY-MM-dd", Locale.ENGLISH); + + public static String formatToTimeInAgoFormat(LocalDateTime dateTime) { + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(dateTime, now); + + if (duration.isNegative()) { + return "in the future"; + } + + long seconds = duration.getSeconds(); + + if (seconds < 60) { + return seconds + "s ago"; + } + + long minutes = duration.toMinutes(); + if (minutes < 60) { + return minutes + "m ago"; + } + + long hours = duration.toHours(); + if (hours < 24) { + return hours + "h ago"; + } + + long days = duration.toDays(); + if (days < 7) { + return days + "d ago"; + } + + long weeks = days / 7; + if (weeks < 4) { + return weeks + "w ago"; + } + + long months = ChronoUnit.MONTHS.between(dateTime.toLocalDate(), now.toLocalDate()); + if (months < 12) { + return months + "mo ago"; + } + + long years = ChronoUnit.YEARS.between(dateTime.toLocalDate(), now.toLocalDate()); + return years + "y ago"; + } + + public static String hashFile(String cbzFilePath) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + try (FileInputStream fis = new FileInputStream(cbzFilePath); + BufferedInputStream bis = new BufferedInputStream(fis)) { + + byte[] buffer = new byte[8192]; + int bytesRead; + + while ((bytesRead = bis.read(buffer)) != -1) { + digest.update(buffer, 0, bytesRead); + } + } + + // Convert byte array to hexadecimal string + byte[] hashBytes = digest.digest(); + StringBuilder hexString = new StringBuilder(); + + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } + + public static String hashBytes(byte[] input) { + // Get an instance of the MessageDigest for the specified algorithm + MessageDigest digest = null; + try { + digest = MessageDigest.getInstance("SHA-512"); + + // Perform the hashing + byte[] hashBytes = digest.digest(input); + + // Convert the byte array to a hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + + return null; + } + + + } + + public static String hashString(String input) { + // Get an instance of the MessageDigest for the specified algorithm + MessageDigest digest = null; + try { + digest = MessageDigest.getInstance("SHA-512"); + + // Perform the hashing + byte[] hashBytes = digest.digest(input.getBytes()); + + // Convert the byte array to a hex string + StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + + return input; + } + + + } + + + public static byte[] loadStaticResourceBytes(String resourcePath) { + try { + ClassPathResource resource = new ClassPathResource("static/" + resourcePath); + return resource.getInputStream().readAllBytes(); + } catch (IOException e) { + // log.error("Failed to load static resource: " + resourcePath, e); + return null; + } + } + + /** + * Saves a byte array to a file at the specified path. + * Creates parent directories if they don't exist. + * + * @param data The byte array to save + * @param filePath The path where the file should be saved (can be absolute or relative) + * @throws IOException if there's an error writing the file + */ + public static void saveByteArrayToFile(byte[] data, Path filePath) throws IOException { + Files.write(filePath, data, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + public static byte[] readFileToByteArray(Path filePath) throws IOException { + return Files.readAllBytes(filePath); + } + + +} diff --git a/src/main/java/com/voidsoftware/map02/validation/ValidZoneId.java b/src/main/java/com/voidsoftware/map02/validation/ValidZoneId.java new file mode 100644 index 0000000..6ce7a3c --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/validation/ValidZoneId.java @@ -0,0 +1,16 @@ +package com.voidsoftware.map02.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = ValidZoneIdValidator.class) +public @interface ValidZoneId { + String message() default "Invalid IANA timezone (e.g. 'Europe/Lisbon')"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/voidsoftware/map02/validation/ValidZoneIdValidator.java b/src/main/java/com/voidsoftware/map02/validation/ValidZoneIdValidator.java new file mode 100644 index 0000000..3396904 --- /dev/null +++ b/src/main/java/com/voidsoftware/map02/validation/ValidZoneIdValidator.java @@ -0,0 +1,20 @@ +package com.voidsoftware.map02.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.ZoneId; + +public class ValidZoneIdValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isBlank()) return false; + try { + ZoneId.of(value); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..933c0ec --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,22 @@ +spring.application.name=map02 +spring.datasource.url=jdbc:postgresql://192.168.1.2:5432/medmap +spring.datasource.username=medmap +spring.datasource.password=medmap +spring.datasource.driver-class-name=org.postgresql.Driver +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +server.port=8080 +# JWT Configuration +jwt.secret.key=${JWT_SECRET_KEY:LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktnd2dnU2tBZ0VBQW9JQkFRRENxdGp1YVhGU2M2QksKaTYyRXBpQVFZTm4yeUV1NWlpdFY5SkZOUUZkQml6RlVjRzdmS2hWNXllZE95VzY0ak0rcVNBNG5WWDFKd1ZhZQpWWnV4RE50eHNDVXdzT0ZWVlBoYVROdkVabXgwWVhuUUpIUlgzbVFGYVIvYy9jdnkyQSt0MDFheUhsR2lTdmZYCkp2UmowcDZ1d29sNk9DTE9pMnRid0hGeVc4bXVUK2Q2OG1RK1JMY1FSUUpBbFpkUW5pbFNzdlY4QTVzYkIraHUKa1JlckRnb0wveHQ4cUxKbWxBYW5XTCtiU2g4Z2c0TUJvNmc4dHowY3BjZHpoKytHVEdHYWQzb3BjSHI2U0JRUQpWdmZiWFJIbHpwb0Q3aEhwWDcrbGkyREJRTUJKSXZZbmlBSjFiK0diK1NUa1ZPbk5mdkpLU1FCVFp6d1c1VURDClB0cFRrQlU3QWdNQkFBRUNnZ0VBRndnenhuRi9VT3dSU1FDaHRSSWF0UlZnZEl4YU9VZVp2UC9FdVVrbGljRzQKUW05VER1Qmh6aGtQWHdrQ0twK3VhazFBTDNNejhhZ0lZWTlyYmxWQkdZQm54ZzBIZkJzamp6dU12UDRKVXZPOQpmOXp3VHYwbkJHVGR1ajNsQllSK3htZzgxN0l5b21zUVM4cDJHK0Z0bU91M2JGaDc5Y2dWR2JQNU9EaVg4aUdaCk5JaUtPZytjazY3cmpGVXQvZjJLN251MHYrcUE5b2ZRNHJhLzNzbjViOUczVUdNZ054T21qdDdRK1JyQmNOVWYKNDJEd2FZV2tsb1o2VzgyWnlVRnBNTXZqU052UTZJNTBWaDJubVRDMDNmeWRtUjVnS1JEZXhoc3pBUGxoMWVQSgpnRzhwVmRmMlV0NkhlWWpYNGd6WHBuRC9lQjFnaFFhbWhNUEkrREpLa1FLQmdRRFVyQTV5SVAranFyU2YvZnFOCkI5NUIzajkrMzdUQlNkZTdBWlVvaDRka1pUazBGRkcxWWZ2YWlHYkFoTDNydUZIemdubUFRdXVkV3JCRXlCM2gKbFc3UHVKSG9uVDBZbmcyQkRtOHJvaWV6MW92M1EyNmVnVWZlMklQQmx2RXBsMjJZQ1ZlTEtxRDlJMXBsMmI0bwpTNkNQemU0S3NDUE40UHRRU3FUWXZ3cnYvUUtCZ1FEcVU4Q0hRZHNrVXVUY3RJelZPUjJqQytkZlA0OVZaRmdsCko0ZzZ3a1NzMndmMmhtOW1WRzM5cnkwcU5XU1FoZ1ZMM1czSE9kZldCM1F4a3k2MDUxRk9kVitHS25CNFZnc0EKdXkvcGxFRWVjd3lpOVhTRGVHOU94bjVmdEUxQU4rVVB6R3Yzam1Gb2toUm1zTWJwUzAyNHo2b1JJcjEvOWFDcApHNHVONVR2VGx3S0JnUURIcGs1WmJna1BTSzRRbEltbDVBRU9yT25HM1pQKzhLZUpjMDdub1hQRkdCRy9obU8rCmQ2SEw0Y2swYW1rdzVQUnFPYzUrVGxTS2dCeUNOYXQzbnFWTWMrVG1EdGJRU1hBNys2VDV2Vnl3UTNWYVJ2K04KNWFmbTJETzRoVThZMElPZCt3Y29LbmkvcTNTdHNPQUhUQmdMYUROVDdVOXZjd242c2xoVzhlSCtzUUtCZ0NJNQpnVkZBQ1NYUkJhUkdxcHNwOEc0eit1TXFnV0RUZXh4U0hjK1hPRGlXOVI3SW05U3lXQzVKTzZ1Q2lPR2k4WHNYCktFV0U2VVZWYVY4a1JCb2Yvdndhb0kwVC9jRlZyOTIzZVZRQVYxSi9NMFZKM0c2blZIcUkrMFEzTFdGSHlLajYKRG5ybHpNeGRLYVdtQkNkbTNEODE5WHp6T2dKS2VlQzVLVFRjV2xTWkFvR0JBTEhPT0pmclNMVUpRV1Y4NDRJQwphRUtCTHhFU3JEamI5dzZQTFkrZ1R3TTY3S1VTdG5RK2dTa1NvZ1h5c2JZVVJIQ3FqWDhxRVNFM05zcjlsRTl5CmtlbVFZUFk0cmZoZDF0WkZVek5WY3F3US9zcC9VTkNVUmY3OVZhRFlUcUkxYy9UZHNqMW5KMDZXVU15Vno4U2kKb2NORWpFVnpkRFNGVkVyaU1lZHJFS3lsCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K} +jwt.public.key=${JWT_PUBLIC_KEY:LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF3cXJZN21seFVuT2dTb3V0aEtZZwpFR0RaOXNoTHVZb3JWZlNSVFVCWFFZc3hWSEJ1M3lvVmVjbm5Uc2x1dUl6UHFrZ09KMVY5U2NGV25sV2JzUXpiCmNiQWxNTERoVlZUNFdremJ4R1pzZEdGNTBDUjBWOTVrQldrZjNQM0w4dGdQcmROV3NoNVJva3IzMXliMFk5S2UKcnNLSmVqZ2l6b3RyVzhCeGNsdkpyay9uZXZKa1BrUzNFRVVDUUpXWFVKNHBVckwxZkFPYkd3Zm9icEVYcXc0SwpDLzhiZktpeVpwUUdwMWkvbTBvZklJT0RBYU9vUExjOUhLWEhjNGZ2aGt4aG1uZDZLWEI2K2tnVUVGYjMyMTBSCjVjNmFBKzRSNlYrL3BZdGd3VURBU1NMMko0Z0NkVy9obS9razVGVHB6WDd5U2trQVUyYzhGdVZBd2o3YVU1QVYKT3dJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==} +jwt.expiration.minutes=${JWT_EXPIRATION_MINUTES:60} +jwt.issuer=${JWT_ISSUER:Desynced Tech} +scripts.root.folder=${FILESYSTEM_ROOT:fileroot/} +# Dose-taking simulation (demo/staging only) - randomly marks due doses as TAKEN to fake real usage +simulation.dose-taking.enabled=${SIMULATE_DOSE_TAKING:false} +simulation.dose-taking.interval-ms=${SIMULATE_DOSE_TAKING_INTERVAL_MS:60000} +simulation.dose-taking.initial-delay-ms=${SIMULATE_DOSE_TAKING_INITIAL_DELAY_MS:60000} +simulation.dose-taking.sample-size=${SIMULATE_DOSE_TAKING_SAMPLE_SIZE:5} +simulation.dose-taking.take-probability=${SIMULATE_DOSE_TAKING_PROBABILITY:0.7} \ No newline at end of file diff --git a/src/main/resources/db/manual/postgresql-search-indexes.sql b/src/main/resources/db/manual/postgresql-search-indexes.sql new file mode 100644 index 0000000..b0513ca --- /dev/null +++ b/src/main/resources/db/manual/postgresql-search-indexes.sql @@ -0,0 +1,23 @@ +-- OPTIONAL PostgreSQL-only deployment script. Not executed automatically by the application. +-- Run AFTER Hibernate has created the tables, using an account allowed to install pg_trgm. +-- Run statements in autocommit mode: CREATE INDEX CONCURRENTLY cannot run in a transaction block. +-- Verify generated SQL/EXPLAIN (ANALYZE, BUFFERS) on representative data before production rollout. +-- Failed concurrent builds can leave invalid indexes; inspect pg_index.indisvalid before retrying +-- (IF NOT EXISTS checks the name, not validity or the index definition). +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- AppUserRepository.findAdminUsers explicitly uses LOWER(email) LIKE '%text%'. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_app_users_email_trgm + ON app_users USING gin (lower(email) gin_trgm_ops); + +-- Spring Data JPA ContainingIgnoreCase derives UPPER(name) LIKE UPPER('%text%'). +-- The indexed expression must match the generated query; LOWER indexes are not interchangeable. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_company_name_trgm + ON company USING gin (upper(name) gin_trgm_ops); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_professional_name_trgm + ON professionals USING gin (upper(name) gin_trgm_ops); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_patient_name_trgm + ON patient USING gin (upper(name) gin_trgm_ops); + +-- These indexes filter substring searches, not ORDER BY name/id or email/id. +-- Empty strings and very short patterns may still require a sequential/full index scan. \ No newline at end of file diff --git a/src/main/resources/static/css/admin-dashboard.css b/src/main/resources/static/css/admin-dashboard.css new file mode 100644 index 0000000..ab78380 --- /dev/null +++ b/src/main/resources/static/css/admin-dashboard.css @@ -0,0 +1,123 @@ +/* Admin board shares the dashboard shell, topbar, cards, tags and design tokens. */ +.admin-board { max-width: 1500px; } +.admin-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 34px 0 26px; } +.admin-heading h1 { font: 600 2rem/1.2 var(--font-display); color: var(--color-primary-dark); margin: 10px 0 8px; } +.admin-heading p, .directory-toolbar p { margin: 0; color: var(--color-text-muted); font-size: 0.9rem; } +.admin-dose-generation { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 24px; padding: 24px; margin-bottom: 28px; } +.admin-dose-generation > div { flex: 1 1 260px; } +.admin-dose-generation .section-title { margin: 0 0 10px; font-size: 1.15rem; } +.admin-dose-generation p { color: var(--color-text-muted); font-size: 0.8rem; line-height: 1.5; margin: 0; } +.admin-dose-form { flex: 1 1 340px; display: grid; gap: 12px; min-width: 0; } +.admin-dose-controls { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 12px; } +.admin-dose-controls label { display: block; font-size: 0.85rem; font-weight: 700; margin-bottom: 6px; } +.admin-dose-controls input { box-sizing: border-box; width: 140px; min-height: 40px; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 9px; background: white; color: var(--color-text); font: inherit; } +.admin-dose-controls svg { width: 18px; height: 18px; margin-right: 8px; } +.admin-dose-confirmation { display: flex; align-items: flex-start; gap: 8px; font-size: 0.8rem; } +.admin-dose-confirmation input { flex-shrink: 0; accent-color: var(--color-primary); } +.admin-button { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 9px 16px; border: 1px solid var(--color-primary); border-radius: 9px; background: var(--color-primary); color: white; font: 600 0.85rem var(--font-base); text-decoration: none; cursor: pointer; white-space: nowrap; } +.admin-button:hover { background: var(--color-primary-dark); border-color: var(--color-primary-dark); } +.admin-button-secondary { background: white; color: var(--color-primary); border-color: var(--color-border); } +.admin-button-secondary:hover { background: #eaf1fb; color: var(--color-primary-dark); border-color: var(--color-primary); } +.admin-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin-bottom: 28px; } +.admin-stat { padding: 24px; display: flex; flex-direction: column; gap: 10px; text-decoration: none; } +.admin-stat > span { color: var(--color-primary-dark); font-weight: 700; } +.admin-stat > strong { color: var(--color-primary); font: 600 2.5rem/1 var(--font-display); } +.admin-stat small { color: var(--color-text-muted); display: flex; justify-content: space-between; gap: 12px; } +.admin-stat small span { color: var(--color-primary); font-size: 1.15rem; } +.admin-directory { padding: 0 26px 24px; } +.admin-directory:hover { box-shadow: var(--shadow-card); } +.admin-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--color-border); overflow-x: auto; } +.admin-tabs a { padding: 22px 0 18px; color: var(--color-text-muted); text-decoration: none; border-bottom: 3px solid transparent; font-weight: 700; white-space: nowrap; } +.admin-tabs a:hover, .admin-tabs a.is-active { color: var(--color-primary); } +.admin-tabs a.is-active { border-color: var(--color-primary); } +.directory-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 18px; padding: 26px 0; } +.directory-toolbar .section-title { margin: 0 0 5px; font-size: 1.2rem; } +.admin-search { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.admin-search input[type="search"] { min-height: 40px; width: 240px; max-width: 100%; padding: 9px 12px; background: var(--color-bg); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 9px; font: inherit; font-size: 0.85rem; } +.clear-search { color: var(--color-primary); font-size: 0.85rem; } +.visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.admin-table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--color-border); border-radius: 12px; } +.admin-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.85rem; } +.admin-table th { background: #f5f8fc; color: var(--color-primary-dark); font-size: 0.75rem; letter-spacing: 0.02em; white-space: nowrap; } +.admin-table th, .admin-table td { padding: 16px; vertical-align: middle; border-bottom: 1px solid var(--color-border); } +.admin-table tbody tr:last-child td { border-bottom: 0; } +.admin-table tbody tr:hover { background: #fafcff; } +.admin-table strong { color: var(--color-primary-dark); } +.admin-table small, .hierarchy-name small, .professional-contact small { display: block; margin-top: 5px; font-size: 0.75rem; color: var(--color-text-muted); } +.record-id { font-size: 0.7rem; color: var(--color-text-muted); overflow-wrap: anywhere; } +.admin-status { display: inline-flex; align-items: center; gap: 6px; border-radius: var(--pill-radius); padding: 5px 10px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; } +.admin-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.is-enabled { background: #eaf6ed; color: #2f753c; } +.is-disabled { background: #f1f2f5; color: #666b75; } +.user-actions-cell { min-width: 96px; } +.user-actions { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; } +.user-actions > form, .user-actions > details { flex-shrink: 0; } +.user-actions form { margin: 0; } +.user-action-button { min-height: 34px; padding: 6px 12px; font-size: 0.75rem; } +.account-icon-button { box-sizing: border-box; width: 40px; height: 40px; min-height: 40px; padding: 0; } +.account-icon-button svg { width: 19px; height: 19px; flex-shrink: 0; } +.admin-button-danger { color: #a52c39; background: #fff2f3; border-color: #efc4ca; } +.admin-button-danger:hover { color: #85202b; background: #ffe4e8; border-color: #ce8791; } +.delete-account > summary { list-style: none; } +.delete-account[open] { width: 280px; max-width: 100%; } +.delete-confirmation { padding: 14px 0 0; max-width: 280px; font-size: 0.8rem; } +.delete-confirmation p { margin: 8px 0; overflow-wrap: anywhere; } +.delete-confirmation label { display: flex; align-items: flex-start; gap: 8px; margin: 14px 0; } +.delete-confirmation input[type="checkbox"] { accent-color: #a52c39; flex-shrink: 0; } +.admin-feedback { margin: 20px 0 0; padding: 14px 16px; border: 1px solid; border-radius: 10px; font-size: 0.9rem; } +.admin-feedback.is-success { background: #eaf6ed; color: #2f753c; border-color: #c5e4cd; } +.admin-feedback.is-error { background: #fff2f3; color: #a52c39; border-color: #efc4ca; } +.prescription-generator { margin-top: 12px; } +.prescription-generator > summary { width: fit-content; list-style: none; } +.prescription-generator-form { display: grid; gap: 12px; width: 290px; max-width: 100%; margin-top: 12px; padding: 16px; border: 1px solid var(--color-border); border-radius: 12px; background: #f5f8fc; } +.prescription-generator-form p { margin: 0; color: var(--color-text-muted); font-size: 0.8rem; line-height: 1.5; } +.prescription-generator-form label { display: grid; gap: 6px; font-size: 0.8rem; } +.prescription-generator-form input[type="number"] { box-sizing: border-box; width: 100%; min-height: 40px; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 8px; background: white; color: var(--color-text); font: inherit; } +.prescription-generator-form .seed-confirmation { display: flex; align-items: flex-start; gap: 8px; } +.seed-confirmation input { flex-shrink: 0; accent-color: var(--color-primary); } +.directory-hint { margin: 0 0 18px; font-size: 0.85rem; color: var(--color-text-muted); } +.company-list, .professional-list { display: grid; gap: 12px; } +.company-row { border: 1px solid var(--color-border); border-radius: 14px; } +.company-row > summary, .professional-row > summary { display: flex; align-items: center; gap: 16px; padding: 18px; cursor: pointer; list-style: none; } +summary::-webkit-details-marker { display: none; } +.company-row > summary:hover, .professional-row > summary:hover { background: #f6f9fd; border-radius: 14px; } +.company-icon { display: grid; place-items: center; width: 46px; height: 46px; flex-shrink: 0; border-radius: 12px; background: #eaf1fb; color: var(--color-primary); } +.company-icon svg { width: 26px; height: 26px; } +.hierarchy-name { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.hierarchy-name strong { color: var(--color-primary-dark); font-size: 0.95rem; } +.company-counts { display: flex; flex-wrap: wrap; gap: 8px; } +.expand-indicator { color: var(--color-primary); font-size: 1.25rem; transition: transform 0.15s; } +details[open] > summary > .expand-indicator { transform: rotate(180deg); } +.company-team { padding: 0 18px 18px; } +.company-info { display: flex; flex-wrap: wrap; gap: 12px; margin: 2px 0 18px; font-size: 0.8rem; color: var(--color-text-muted); } +.professional-row { border: 1px solid var(--color-border); border-radius: 12px; background: white; } +.professional-contact { font-size: 0.8rem; overflow-wrap: anywhere; max-width: 30%; } +.admin-initial { display: grid; place-items: center; width: 38px; height: 38px; color: var(--color-primary); background: #edf2f9; font-weight: 700; } +.professional-patients { padding: 0 16px 16px; } +.professional-patients > .record-id { margin: 0 0 12px; } +.admin-empty { text-align: center; padding: 50px 20px; color: var(--color-text-muted); } +.admin-empty h3 { color: var(--color-primary-dark); margin: 0 0 10px; } +.admin-empty-inline { padding: 16px; margin: 0; color: var(--color-text-muted); font-size: 0.85rem; } +.admin-pagination { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 18px; padding-top: 24px; font-size: 0.85rem; color: var(--color-text-muted); } +a:focus-visible, button:focus-visible, input:focus-visible, summary:focus-visible, .admin-table-scroll:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; } +@media (max-width: 900px) { + .admin-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .company-counts { flex-direction: column; } + .professional-contact { display: none; } +} +@media (max-width: 600px) { + .app-shell { padding: 20px 16px 40px; } + .admin-heading { align-items: flex-start; flex-direction: column; } + .admin-heading h1 { font-size: 1.65rem; } + .admin-stats { gap: 12px; } + .admin-stat { padding: 18px; } + .admin-stat small { font-size: 0.7rem; } + .admin-directory { padding: 0 14px 20px; } + .admin-tabs { gap: 18px; font-size: 0.85rem; } + .admin-search { width: 100%; } + .admin-search input[type="search"] { flex: 1; min-width: 100px; width: auto; } + .company-row > summary, .professional-row > summary { gap: 8px; padding: 12px; flex-wrap: wrap; } + .company-counts { flex-direction: row; width: 100%; padding-left: 54px; } + .company-team { padding: 0 10px 12px; } + .professional-patients { padding: 0 8px 12px; } +} \ No newline at end of file diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css new file mode 100644 index 0000000..97e92be --- /dev/null +++ b/src/main/resources/static/css/base.css @@ -0,0 +1,246 @@ +/* ============================================================================ + base.css — shared design tokens & reusable components for every MAP02 page. + Page-specific stylesheets are loaded after this one and may override freely. + ============================================================================ */ + +:root { + --color-bg: #eef2f8; + --color-primary: #4078bc; + --color-primary-dark: #1b3d6e; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-white: #ffffff; + --color-success: #4caf50; + --color-danger: #e5484d; + --color-warning: #f5a623; + --color-info: #4078bc; + --color-border: #e2e8f0; + --card-radius: 16px; + --pill-radius: 999px; + --shadow-card: 0 2px 10px rgba(17, 24, 39, 0.06); + --shadow-card-hover: 0 6px 18px rgba(17, 24, 39, 0.10); + --font-base: 'Nunito', Arial, Helvetica, sans-serif; + --font-display: 'Outfit', sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; } + +body { + font-family: var(--font-base); + background: var(--color-bg); + color: var(--color-text); +} + +a { color: inherit; } + +/* -- Page shell ------------------------------------------------------------ */ +.app-shell { + /* max-width: 1180px; */ + margin: 0 auto; + padding: 28px 32px 56px; +} + +/* -- Top bar ---------------------------------------------------------------- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-logo { width: 440px; height: 84px; border-radius: 10px; } + +.brand-title { + font-family: var(--font-display); + font-weight: 700; + font-size: 1.25rem; + color: var(--color-primary-dark); + line-height: 1.1; +} + +.brand-subtitle { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.topbar-user { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-user-name { + text-align: right; + line-height: 1.25; +} + +.topbar-user-greeting { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.topbar-user-fullname { + font-weight: 700; + color: var(--color-primary-dark); +} + +/* Reusable header: keep the dashboard appearance on pages with local tokens. */ +.shared-topbar { + --color-primary-dark: #1b3d6e; + --color-text-muted: #6b7280; + --color-border: #e2e8f0; + font-family: var(--font-base); + gap: 20px; +} +.shared-topbar .brand { min-width: 0; flex: 0 1 440px; } +.shared-topbar .brand-logo { max-width: 100%; height: auto; } +.shared-topbar .topbar-user { min-width: 0; } +.shared-topbar .topbar-user-name { min-width: 0; } +.shared-topbar .topbar-user-fullname { overflow-wrap: anywhere; } +.topbar-logout { margin: 0; flex-shrink: 0; padding-left: 12px; border-left: 1px solid var(--color-border); } +.topbar-logout-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 44px; + min-height: 44px; + padding: 10px 14px; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-white); + color: var(--color-primary-dark); + font: 600 0.85rem var(--font-base); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.topbar-logout-button svg { width: 19px; height: 19px; flex-shrink: 0; } +.topbar-logout-button:hover { background: #edf3fb; border-color: #a8c3e5; color: #4078bc; } +.topbar-logout-button:active { background: #e0ebf8; } +.topbar-logout-button:focus-visible { outline: 2px solid #4078bc; outline-offset: 3px; } +.topbar-avatar { width: 44px; height: 44px; } +.topbar-avatar-initial { + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--color-primary-dark); +} +@media (max-width: 600px) { + .shared-topbar { gap: 12px; } + .shared-topbar .brand { flex-basis: 50%; } + .shared-topbar .topbar-user { gap: 8px; } + .shared-topbar .topbar-user-fullname { font-size: 0.85rem; } + .topbar-logout { padding-left: 8px; } + .topbar-logout-button { padding: 10px; } + .topbar-logout-button span { display: none; } +} + +/* -- Section headings -------------------------------------------------------- */ +.section-title { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.1rem; + color: var(--color-primary-dark); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +/* -- Card --------------------------------------------------------------------- */ +.card { + background: var(--color-white); + border-radius: var(--card-radius); + box-shadow: var(--shadow-card); + transition: box-shadow 0.2s ease, transform 0.2s ease; +} + +.card:hover { box-shadow: var(--shadow-card-hover); } + +/* -- Avatars -------------------------------------------------------------------- */ +.avatar { + border-radius: 50%; + object-fit: cover; + background: var(--color-border); + flex-shrink: 0; +} + +.avatar-ring-good { box-shadow: 0 0 0 3px var(--color-success); } +.avatar-ring-bad { box-shadow: 0 0 0 3px var(--color-danger); } + +/* -- Tags / badges ---------------------------------------------------------------- */ +.tag { + display: inline-block; + padding: 3px 10px; + border-radius: var(--pill-radius); + background: rgba(64, 120, 188, 0.12); + color: var(--color-primary); + font-size: 0.72rem; + font-weight: 600; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.dot-critical { background: var(--color-danger); } +.dot-warning { background: var(--color-warning); } +.dot-info { background: var(--color-primary); } + +/* -- Icon buttons ------------------------------------------------------------------ */ +.icon-btn { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--color-bg); + border: none; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + cursor: pointer; +} + +.icon-btn:hover { background: var(--color-border); } + +/* -- Floating action button ---------------------------------------------------------- */ +.btn-fab { + width: 42px; + height: 42px; + border-radius: 50%; + border: none; + background: var(--color-primary-dark); + color: var(--color-white); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + box-shadow: var(--shadow-card); +} + +.btn-fab:hover { background: var(--color-primary); } + +/* -- Progress bar ---------------------------------------------------------------------- */ +.progress-track { + height: 14px; + width: 100%; + border-radius: var(--pill-radius); + background: var(--color-danger); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--color-success); + border-radius: var(--pill-radius) 0 0 var(--pill-radius); +} diff --git a/src/main/resources/static/css/dashboard.css b/src/main/resources/static/css/dashboard.css new file mode 100644 index 0000000..609ea89 --- /dev/null +++ b/src/main/resources/static/css/dashboard.css @@ -0,0 +1,179 @@ +/* dashboard.css — layout specific to the professional dashboard (/dashboard). */ + +/* -- Adherence section ---------------------------------------------------- */ +.adherence-section { margin-bottom: 32px; } + +.adherence-bar-row { + display: flex; + align-items: center; + gap: 16px; +} + +.adherence-value { + font-family: var(--font-display); + font-weight: 700; + font-size: 1.4rem; + color: var(--color-primary-dark); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.adherence-value.is-negative { color: var(--color-danger); } + +.adherence-values-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +/* -- Alerts strip ----------------------------------------------------------- */ +.alerts-section { margin-bottom: 36px; } + +.alerts-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 14px; +} + +.alert-card { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border: 1px solid var(--color-border); +} + +.alert-card-avatar { width: 38px; height: 38px; } + +.alert-card-body { flex: 1; min-width: 0; } + +.alert-card-top { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + color: var(--color-text-muted); + margin-bottom: 2px; +} + +.alert-card-name { + font-weight: 700; + font-size: 0.85rem; + color: var(--color-text); +} + +.alert-card-message { + font-size: 0.78rem; + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + color: var(--color-text-muted); + font-size: 0.9rem; + padding: 12px 4px; +} + +/* -- Patients section --------------------------------------------------------- */ +.patients-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.patients-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 16px; +} + +.patient-card { + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +.patient-card:hover { + box-shadow: 0 4px 14px rgba(27, 61, 110, 0.12); + transform: translateY(-2px); +} + +.patient-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +/* Radial dose-adherence ring: background is a per-status conic-gradient set inline (see PatientCardView.ringStyle); + the white border on the avatar carves out the gap between the ring and the photo. */ +.patient-card-avatar-ring { + width: 60px; + height: 60px; + border-radius: 50%; + padding: 3px; + flex-shrink: 0; +} + +.patient-card-avatar-ring .patient-card-avatar { + width: 100%; + height: 100%; + border: 3px solid var(--color-white); + box-sizing: border-box; +} + +.patient-card-avatar { width: 100%; height: 100%; } + +.patient-card-actions { + display: flex; + flex-direction: row; + gap: 6px; +} + +.patient-card-name { + font-weight: 700; + font-size: 0.95rem; + color: var(--color-text); +} + +.patient-card-age { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.patient-card-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.patient-card-meta { + display: flex; + align-items: center; + gap: 14px; + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: auto; +} + +.patient-card-meta-item { + display: flex; + align-items: center; + gap: 4px; +} + +/* -- Responsive ------------------------------------------------------------------- */ +@media (max-width: 600px) { + .app-shell { padding: 20px 16px 40px; } + .adherence-bar-row { flex-direction: column; align-items: stretch; } +} diff --git a/src/main/resources/static/css/login.css b/src/main/resources/static/css/login.css new file mode 100644 index 0000000..20d16c1 --- /dev/null +++ b/src/main/resources/static/css/login.css @@ -0,0 +1,297 @@ +:root { + --blue: #4078BC; + --blue-dark: #3a7bc8; + --blue-btn: #88BDFF; + --white: #ffffff; + --white-dim: rgba(255, 255, 255, 0.6); + --white-muted: rgba(255, 255, 255, 0.15); + --white-border: rgba(255, 255, 255, 0.45); + --field-background: #5d8cc6; + --brand-left: #1b3d6e; + --form-width: 40%; + --form-min-width: 360px; + --form-content-width: 75%; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: Arial, Helvetica, sans-serif; + background: #111827; + overflow: hidden; +} + +body { + display: flex; + min-height: 100vh; +} + +/* -- Card shell ----------------------------------------------------------- */ +.page-shell { + width: 100%; + display: flex; +} + +.login-card { + display: flex; + width: 100%; + height: 100vh; +} + +/* -- Brand panel (left) --------------------------------------------------- */ +.brand-panel { + flex: 1; + position: relative; + overflow: hidden; +} + +.brand-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* -- Form panel (right) --------------------------------------------------- */ +.form-panel { + flex: 0 0 var(--form-width); + min-width: var(--form-min-width); + background: var(--blue); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 48px 40px 28px; + color: var(--white); + overflow-y: auto; + gap: 24px; +} + +/* -- Form content wrapper ------------------------------------------------- */ +.form-content { + width: var(--form-content-width); + margin: 0 auto; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +/* -- Form brand block ----------------------------------------------------- */ +.form-brand { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 24px; +} + +.form-brand-line1, +.form-brand-line2 { + margin: 0; + color: var(--white); + font-size: 0.95rem; + line-height: 1.4; +} + +.form-header h1 { + font-size: 1.45rem; + font-weight: 700; + line-height: 1.4; + margin-bottom: 32px; +} + +/* -- Form fields ----------------------------------------------------------- */ +.login-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field-group { width: 100%; } + +.field-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.input-wrap { + display: flex; + align-items: center; + background: var(--field-background); + border: 1px solid var(--white-border); + border-radius: 8px; + padding: 0 14px; + height: 46px; + gap: 10px; + transition: border-color 0.2s; +} + +.input-wrap:focus-within { + border-color: var(--white); + box-shadow: 0 0 0 2px var(--white-muted); +} + +.field-icon { width: 18px; height: 18px; flex-shrink: 0; } + +.input-wrap input, +.input-wrap select { + flex: 1 1 0%; + width: 100%; + min-width: 0; + height: 100%; + padding: 0; + margin: 0; + border: none; + border-radius: 0; + outline: none; + background: transparent; + color: var(--white); + caret-color: var(--white); + font: 400 0.95rem / 1.4 var(--font-base); + letter-spacing: normal; + text-align: left; + box-shadow: none; + appearance: none; +} + +.input-wrap input:focus, +.input-wrap select:focus { + background: transparent; + color: var(--white); +} + +.input-wrap input::placeholder { + color: var(--white-dim); + font: inherit; + opacity: 1; +} + +/* Browsers paint saved credentials independently of the input background. */ +.input-wrap input:autofill { + box-shadow: 0 0 0 1000px var(--field-background) inset; + -webkit-text-fill-color: var(--white); + caret-color: var(--white); +} +.input-wrap input:-webkit-autofill, +.input-wrap input:-webkit-autofill:hover, +.input-wrap input:-webkit-autofill:focus, +.input-wrap input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 1000px var(--field-background) inset; + -webkit-text-fill-color: var(--white); + caret-color: var(--white); +} + +.input-wrap select { cursor: pointer; } +.input-wrap select:invalid { color: var(--white-dim); } +.input-wrap select option { color: #0a2647; background: var(--white); } +.select-chevron { width: 16px; height: 16px; flex-shrink: 0; pointer-events: none; } +.field-group > .error-msg:empty { display: none; } +.field-group > .error-msg:not(:empty) { margin-top: 6px; } + +.account-link { + margin-top: 24px; + text-align: center; + color: rgba(255, 255, 255, 0.88); + font-size: 0.9rem; + line-height: 1.5; +} +.account-link a { color: var(--white); font-weight: 700; text-underline-offset: 3px; } +.account-link a:hover { text-decoration: none; } +.account-link a:focus-visible, +.recover-link:focus-visible, +.btn-login:focus-visible { + outline: 2px solid var(--white); + outline-offset: 4px; +} + +/* -- Button --------------------------------------------------------------- */ +.btn-login { + width: 100%; + height: 46px; + margin-top: 8px; + border: none; + border-radius: 8px; + background: var(--blue-btn); + color: #0a2647; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s; +} + +.btn-login:hover { background: #a8d4ff; } + +/* -- Error ---------------------------------------------------------------- */ +.login-info { + padding: 10px 12px; + border: 1px solid var(--white-border); + border-radius: 8px; + background: var(--white-muted); + color: var(--white); + font-size: 0.85rem; + text-align: center; +} + +.error-msg { + font-size: 0.85rem; + color: #ffe08a; + text-align: center; +} + +/* -- Recover link --------------------------------------------------------- */ +.recover-link { + display: block; + text-align: left; + margin-top: 16px; + color: rgba(255, 255, 255, 0.88); + font-size: 0.9rem; + text-decoration: none; +} + +.recover-link:hover { text-decoration: underline; } + +/* -- Footer --------------------------------------------------------------- */ +.form-footer { + flex-shrink: 0; + padding-top: 20px; + border-top: 1px solid var(--white-muted); + display: flex; + justify-content: center; +} + +.footer-signature { + max-width: 100%; + height: auto; + max-height: 48px; + opacity: 0.85; +} + +/* -- Outfit ExtraLight 200 ----------------------------------------------- */ +.text-outfit-xl { + font-family: 'Outfit', sans-serif; + font-weight: 200; + font-size: 33.44px; + line-height: 1; + letter-spacing: 0; +} + +/* -- Nunito Regular 400 (approx. LINE Seed JP) --------------------------- */ +.text-lineseed-rg { + font-family: 'Nunito', sans-serif; + font-weight: 400; + font-size: 14.56px; + line-height: 1; + letter-spacing: 0; +} + +/* -- Responsive ----------------------------------------------------------- */ +@media (max-width: 720px) { + .brand-panel { display: none; } + .form-panel { flex: 1; min-width: 0; padding: 32px 24px 24px; } + .form-content { width: 100%; max-width: 420px; } +} diff --git a/src/main/resources/static/css/patient-detail.css b/src/main/resources/static/css/patient-detail.css new file mode 100644 index 0000000..1711bc9 --- /dev/null +++ b/src/main/resources/static/css/patient-detail.css @@ -0,0 +1,172 @@ +/* Full-page patient design. Reuses base.css components and tokens; + scoped overrides leave dashboard and authentication layouts unchanged. */ +.patient-detail-page { + --color-primary: #407cc5; + --color-primary-dark: #100c9f; + --color-text: #141421; + --color-text-muted: #898c89; + --color-success: #7ada49; + --color-warning: #f8be6d; + --color-danger: #ff6570; + --color-border: #d7d7d7; + --card-radius: 22px; + margin: 0; + background: var(--color-bg); + font-family: var(--font-display); + font-weight: 400; + -webkit-font-smoothing: antialiased; +} +.patient-detail { + position: relative; + width: calc(100% - 32px); + max-width: 790px; + margin: 28px auto 40px; + padding: 32px 64px 64px; + background: var(--color-white); + border-radius: var(--card-radius); + box-shadow: var(--shadow-card); +} +.patient-topbar-shell { padding: 28px 32px 0; } +.patient-topbar-shell .topbar { margin-bottom: 0; } +@media (max-width: 600px) { + .patient-topbar-shell { padding: 20px 16px 0; } +} +.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; } +.line-icon { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.patient-profile { text-align: center; margin-bottom: 32px; } +.profile-ring { position: relative; width: 140px; height: 140px; margin: 0 auto 10px; } +.profile-ring-chart { position: absolute; inset: 0; width: 100%; height: 100%; } +.ring-track, .ring-value { fill: none; stroke-width: 6; } +.ring-track { stroke: var(--color-border); } +.ring-track.ring-missed { stroke: var(--tone); } +.ring-value { stroke: var(--tone, var(--color-success)); transform: rotate(-90deg); transform-origin: center; stroke-linecap: round; } +.profile-ring-chart circle { stroke-width: 13; } +.profile-photo { position: absolute; inset: 25px; width: 90px; height: 90px; } +.profile-initial { display: grid; place-items: center; color: var(--color-primary-dark); font-size: 36px; background: #edf2fa; } +.patient-profile h1 { font-size: 28px; font-weight: 600; line-height: 1.2; color: var(--color-primary-dark); margin: 0 0 10px; overflow-wrap: anywhere; } +.profile-meta { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 16px; color: var(--color-primary); font-size: 17px; } +.profile-meta > span, .profile-meta > a { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; } +.profile-meta .line-icon { color: var(--color-primary-dark); } +.profile-meta a:hover { text-decoration: underline; } +.missing-phone { font-size: 14px; } +.profile-tags { list-style: none; padding: 0; display: flex; flex-wrap: wrap; justify-content: center; gap: 7px; margin: 30px 0 0; } +.profile-tags .tag { background: var(--color-primary); color: white; font-size: 14px; font-weight: 400; padding: 8px 15px; max-width: 100%; overflow-wrap: anywhere; } +.section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.section-title { margin: 0; font-size: 19px; font-weight: 300; color: var(--color-primary); gap: 10px; flex-wrap: wrap; } +.period-label { color: var(--color-primary-dark); font-size: 14px; font-weight: 700; } +.period-form { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.period-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } +.mode-toggle-form { margin: 0; } +.view-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 42px; background: transparent; border: 0; border-radius: 8px; color: var(--color-primary-dark); font: inherit; font-size: 19px; padding: 8px 3px; cursor: pointer; } +.view-toggle:hover { background: rgba(64, 124, 197, 0.1); } +.date-picker { position: relative; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 10px; color: white; background: var(--color-primary-dark); } +.date-picker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; min-width: 0; } +.date-picker input::-webkit-calendar-picker-indicator { position: absolute; inset: 0; width: 100%; height: 100%; cursor: pointer; } +.date-picker:focus-within { outline: 3px solid var(--color-primary); outline-offset: 3px; } +.apply-period { border: 1px solid var(--color-border); background: white; color: var(--color-primary-dark); border-radius: 6px; padding: 6px; cursor: pointer; } +.period-form.is-enhanced .apply-period { display: none; } +.weekly-chart { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 10px; padding: 44px 0 18px; } +.week-day { display: flex; flex-direction: column; align-items: center; gap: 9px; } +.week-day:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 6px; } +.week-bar-track { height: 60px; display: flex; align-items: flex-end; } +.week-bar { position: relative; display: block; width: 9px; border-radius: var(--pill-radius); background: var(--tone); } +.week-bar-tooltip { position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); padding: 5px 8px; border-radius: 6px; background: var(--color-primary-dark); color: white; font-size: 12px; font-weight: 700; line-height: 1.2; white-space: nowrap; visibility: hidden; z-index: 2; } +.week-bar-tooltip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 4px solid transparent; border-top-color: var(--color-primary-dark); } +.week-day:hover .week-bar-tooltip, .week-day:focus .week-bar-tooltip { visibility: visible; } +.weekday { color: var(--color-primary); font-size: 13px; font-weight: 300; } +.monthly-chart { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px 15px; padding: 18px 0 22px; justify-items: center; } +.month-day { width: 58px; max-width: 100%; aspect-ratio: 1; position: relative; } +.month-day > svg { display: block; width: 100%; height: 100%; } +.month-day-text { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; line-height: 1; } +.month-day-text strong { color: var(--color-primary-dark); font-size: 16px; font-weight: 700; } +.month-day-text > span { color: var(--color-primary); font-size: 10px; font-weight: 300; } +.tone-good { --tone: var(--color-success); } +.tone-warning { --tone: var(--color-warning); } +.tone-bad { --tone: var(--color-danger); } +.tone-neutral { --tone: var(--color-border); } +.history-footer { display: grid; gap: 0; margin: 0 0 28px; } +.period-navigation { display: flex; align-items: center; gap: 7px; width: fit-content; height: 28px; z-index: 1; } +.period-dot { width: 13px; height: 13px; border-radius: 50%; background: var(--color-border); display: block; position: relative; } +.period-dot::after { content: ''; position: absolute; inset: -5px; } +.period-dot.is-active { background: var(--color-primary); } +.period-arrow { color: var(--color-primary); font-size: 24px; text-decoration: none; width: 20px; text-align: center; } +.history-details { margin-top: -28px; min-width: 0; } +.history-details > summary { width: fit-content; margin-left: auto; color: var(--color-primary); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; font-size: 14px; line-height: 28px; list-style: none; } +.history-details > summary::-webkit-details-marker { display: none; } +.history-explanation { padding: 16px 0 8px; font-size: 13px; color: var(--color-primary); } +.history-explanation p { margin: 6px 0; } +.chart-legend { list-style: none; display: flex; flex-wrap: wrap; gap: 14px; margin: 12px 0; padding: 0; } +.chart-legend li { display: flex; gap: 5px; align-items: center; } +.chart-legend .dot, .dose-status .dot { background: var(--tone); } +.history-table-wrap { max-height: 360px; overflow: auto; border: 1px solid #e8eef7; border-radius: 12px; } +.history-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 13px; font-family: var(--font-base); } +.history-table th { background: #eaf0f9; color: var(--color-primary-dark); position: sticky; top: 0; } +.history-table th, .history-table td { padding: 12px; border-bottom: 1px solid #e8eef7; } +.history-table td:not(:nth-child(3)) { white-space: nowrap; } +.dose-status { display: inline-flex; align-items: center; gap: 6px; } +.prescriptions-toolbar { margin-bottom: 22px; min-height: 38px; } +.prescriptions-count { font-size: 14px; color: var(--color-text-muted); } +.prescription-list { display: grid; gap: 12px; } +.prescription-card { background: #eaf0f9; padding: 21px; box-shadow: none; } +.prescription-card:hover { box-shadow: none; } +.prescription-main { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto auto; align-items: center; gap: 17px; } +.medication-photo { width: 52px; height: 52px; object-fit: cover; border-radius: 12px; } +.medication-placeholder { display: grid; place-items: center; background: white; color: var(--color-primary); } +.medication-placeholder .line-icon { width: 30px; height: 30px; } +.medication-name h3 { margin: 0; font-size: 15px; color: var(--color-primary-dark); font-weight: 600; overflow-wrap: anywhere; } +.medication-name > span { font-size: 12px; color: var(--color-primary); } +.stock-badge { border-radius: var(--pill-radius); background: var(--tone); color: #142c0c; padding: 8px 15px; font-size: 13px; font-weight: 600; text-align: center; } +.stock-badge.tone-good { background: #329f00; color: white; } +.stock-badge.tone-bad { background: #ff6570; color: #420810; } +.stock-badge.tone-neutral { background: white; color: #686d79; font-size: 12px; font-weight: 400; } +.dose-amount { display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 17px; background: white; min-width: 78px; min-height: 52px; padding: 10px 12px; font-family: var(--font-base); font-size: 15px; } +.dose-amount .line-icon { color: var(--color-primary); } +.prescription-schedule { border-top: 1px solid #81a9df; padding-top: 20px; margin-top: 18px; } +.schedule-times { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 18px 12px; } +.schedule-times > span { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 300; } +.schedule-times .line-icon { color: var(--color-primary); } +.schedule-frequency { font-size: 12px; color: var(--color-primary); margin: 10px 0 0; } +.schedule-times + .schedule-frequency { margin-top: 12px; } +.prescription-instructions { font-size: 13px; color: #58677e; margin: 8px 0 0; } +.empty-state { color: #6a778c; font-size: 14px; padding: 10px 0; } +.history-empty { margin: 0 0 12px; text-align: center; } +a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--color-primary-dark); outline-offset: 5px; border-radius: 3px; } +@media (min-width: 1100px) { + .patient-detail { max-width: 950px; padding-top: 40px; } + .weekly-chart { padding-top: 48px; } + .monthly-chart { row-gap: 18px; } +} +@media (max-width: 680px) { + .patient-detail { padding: 30px 24px 40px; } + .section-title { font-size: 17px; gap: 7px; } + .period-label { font-size: 13px; } + .section-toolbar { gap: 10px; } + .period-form { gap: 7px; } + .view-toggle { font-size: 17px; } + .period-controls { gap: 7px; } + .prescription-main { gap: 12px; grid-template-columns: 46px minmax(0, 1fr) auto; } + .medication-photo { width: 46px; height: 46px; } + .stock-badge { grid-column: 2; grid-row: 2; justify-self: start; padding: 6px 12px; } + .dose-amount { grid-column: 3; grid-row: 1 / span 2; } + .monthly-chart { gap: 10px 5px; } + .prescription-card { padding: 18px; } +} +@media (max-width: 420px) { + .patient-detail { padding-inline: 18px; } + .patient-profile h1 { font-size: 25px; } + .profile-meta { font-size: 15px; gap: 10px; } + .profile-tags .tag { padding: 7px 12px; font-size: 12px; } + .section-toolbar { flex-wrap: wrap; } + .dose-history > .section-toolbar { align-items: flex-end; } + .dose-history .section-title { flex: 1; } + .period-label { display: block; width: 100%; margin-left: 31px; } + .date-picker { width: 36px; height: 38px; } + .month-day-text strong { font-size: 14px; } + .month-day-text > span { font-size: 8px; } + .history-details > summary { font-size: 12px; } + .period-navigation { gap: 6px; } + .period-dot { width: 10px; height: 10px; } + .period-arrow { width: 14px; } + .dose-amount { min-width: 58px; gap: 5px; padding: 8px; font-size: 13px; } +} \ No newline at end of file diff --git a/src/main/resources/static/images/Subtract.svg b/src/main/resources/static/images/Subtract.svg new file mode 100644 index 0000000..5fee310 --- /dev/null +++ b/src/main/resources/static/images/Subtract.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/static/images/clockIcon.svg b/src/main/resources/static/images/clockIcon.svg new file mode 100644 index 0000000..44287e4 --- /dev/null +++ b/src/main/resources/static/images/clockIcon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/static/images/dashboardlogo.svg b/src/main/resources/static/images/dashboardlogo.svg new file mode 100644 index 0000000..d2ecafa --- /dev/null +++ b/src/main/resources/static/images/dashboardlogo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/static/images/downloadIcon.svg b/src/main/resources/static/images/downloadIcon.svg new file mode 100644 index 0000000..60766fc --- /dev/null +++ b/src/main/resources/static/images/downloadIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/images/mainimage.png b/src/main/resources/static/images/mainimage.png new file mode 100644 index 0000000..bcc5b3b Binary files /dev/null and b/src/main/resources/static/images/mainimage.png differ diff --git a/src/main/resources/static/images/mem02.svg b/src/main/resources/static/images/mem02.svg new file mode 100644 index 0000000..0125ae2 --- /dev/null +++ b/src/main/resources/static/images/mem02.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/static/images/messageIcon.svg b/src/main/resources/static/images/messageIcon.svg new file mode 100644 index 0000000..57150dc --- /dev/null +++ b/src/main/resources/static/images/messageIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/static/images/signature.svg b/src/main/resources/static/images/signature.svg new file mode 100644 index 0000000..cec5e30 --- /dev/null +++ b/src/main/resources/static/images/signature.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/static/images/smile.svg b/src/main/resources/static/images/smile.svg new file mode 100644 index 0000000..677a7d9 --- /dev/null +++ b/src/main/resources/static/images/smile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/images/suspicious.svg b/src/main/resources/static/images/suspicious.svg new file mode 100644 index 0000000..cc37046 --- /dev/null +++ b/src/main/resources/static/images/suspicious.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/js/patient-detail.js b/src/main/resources/static/js/patient-detail.js new file mode 100644 index 0000000..93ee755 --- /dev/null +++ b/src/main/resources/static/js/patient-detail.js @@ -0,0 +1,10 @@ +// Native GET forms and details remain usable without JavaScript. +const periodForm = document.querySelector('.period-form'); +if (periodForm) { + periodForm.classList.add('is-enhanced'); + periodForm.querySelectorAll('input[type="date"]').forEach(control => { + control.addEventListener('change', () => { + if (periodForm.reportValidity()) periodForm.requestSubmit(); + }); + }); +} \ No newline at end of file diff --git a/src/main/resources/templates/admin-dashboard.html b/src/main/resources/templates/admin-dashboard.html new file mode 100644 index 0000000..2cdb355 --- /dev/null +++ b/src/main/resources/templates/admin-dashboard.html @@ -0,0 +1,155 @@ + + + + +
+
+ +
+
Administrador

Gestão da plataforma

Consulte as contas e a organização das equipas de saúde.

+
+ +
+
+

Gerar doses para todos os pacientes

+

Inclui todas as prescrições ativas e não PRN, independentemente da pesquisa ou do separador atual. As doses existentes são ignoradas.

+
+
+ + + +
+
+ + +
+ +
+

De hoje até hoje + N dias, inclusive (0 = só hoje; máximo 90). A data inicial segue o servidor; os horários seguem o fuso de cada paciente.

+ +
+
+ + + +
+

+ + + +
+
+

Utilizadores

+

0 registos encontrados

+
+ +
+ +
+

Sem resultados

+
+ + +
+ + + + + + + + + + + +
Contas da aplicação, perfis e estado de acesso
UtilizadorPerfilAcessoCriado em (UTC)ID do perfil associadoAções
EmailPerfil
+
+
+ + +

Abra uma empresa para consultar os profissionais e, em seguida, os seus pacientes.

+
+
+ + + Empresa + + + +
+
+

Ainda não existem profissionais associados a esta empresa.

+
+
+
+
+
+ + +

Abra um profissional para consultar os pacientes associados.

+
+
+ + +
+
+ + +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html new file mode 100644 index 0000000..47eb76a --- /dev/null +++ b/src/main/resources/templates/dashboard.html @@ -0,0 +1,121 @@ + + + + + +
+ + +
+ + +
+
Cumprimento das Prescrições
+
+
+
+
+
+ +
+
+ 88% +
+ +
+ 12% +
+
+
+ + +
+
🔔 Alertas
+ +
+
+ +
? +
+
+
+ 2h + +
+
Nome
+
Mensagem
+
+
+
+ +

Sem alertas recentes.

+
+ + +
+
+
👥 Os Seus Pacientes
+ +
+ + + + +

Ainda não tem pacientes associados.

+
+ +
+ + + diff --git a/src/main/resources/templates/fragments/admin-lists.html b/src/main/resources/templates/fragments/admin-lists.html new file mode 100644 index 0000000..ae6020b --- /dev/null +++ b/src/main/resources/templates/fragments/admin-lists.html @@ -0,0 +1,60 @@ + + + +
+ + + + + + + + + + + +
Pacientes e profissionais responsáveis
PacienteContactoProfissional / EmpresaEstado
+ Nome +
+ + Gerar prescrições +
+ Prescrições aleatórias de teste +

Utiliza os medicamentos existentes e o profissional responsável pelo paciente. Não utilizar em cuidados reais.

+ + Os medicamentos podem repetir-se quando a quantidade excede o catálogo disponível. + + + + + + Feche «Gerar prescrições» para cancelar. +
+
+
+
+ +
+
+ + + ProfissionalEmpresa + + 0 pacientes + + +
+

+

Ainda não existem pacientes associados a este profissional.

+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html new file mode 100644 index 0000000..853c281 --- /dev/null +++ b/src/main/resources/templates/fragments/head.html @@ -0,0 +1,14 @@ + + + + + + MAP02 + + + + + + + + diff --git a/src/main/resources/templates/fragments/topbar.html b/src/main/resources/templates/fragments/topbar.html new file mode 100644 index 0000000..7354778 --- /dev/null +++ b/src/main/resources/templates/fragments/topbar.html @@ -0,0 +1,31 @@ + + + +
+
+ +
+
+
+
Olá
+
Dra. Nome
+
+ +
D
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..d14fd19 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,80 @@ + + + + + +
+ +
+ + + diff --git a/src/main/resources/templates/patient-detail.html b/src/main/resources/templates/patient-detail.html new file mode 100644 index 0000000..2502c36 --- /dev/null +++ b/src/main/resources/templates/patient-detail.html @@ -0,0 +1,138 @@ + + + + +
+
+
+ + +
+
+ +

Nome do paciente

+
+ 76 Anos + Telefone + Telefone não registado +
+ +
    +
  • Medicação
  • +
+
+ +
+
+

Histórico de tomas Julho 2026

+
+
+ + +
+
+ +
+ + + +
+ +
+
+
+
+ +
+
+ + +
+

Sem tomas previstas neste período.

+ +
+
+
+

Prescrições

+ 0 ativas +
+

Sem prescrições ativas.

+
+
+
+ + +

Nome do Medicamento

+ Stock não registado + 1 +
+
+
+

Diariamente

+

+
+
+
+
+
+ + + diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 0000000..e1970e0 --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,97 @@ + + + + + +
+ +
+ + + diff --git a/src/test/java/com/voidsoftware/map02/Map02ApplicationTests.java b/src/test/java/com/voidsoftware/map02/Map02ApplicationTests.java new file mode 100644 index 0000000..74c8323 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/Map02ApplicationTests.java @@ -0,0 +1,9 @@ +package com.voidsoftware.map02; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class Map02ApplicationTests { + + +} diff --git a/src/test/java/com/voidsoftware/map02/client/MedMapClientTest.java b/src/test/java/com/voidsoftware/map02/client/MedMapClientTest.java new file mode 100644 index 0000000..1988eb4 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/client/MedMapClientTest.java @@ -0,0 +1,240 @@ +package com.voidsoftware.map02.client; + +import com.voidsoftware.map02.entities.MedicationDose; +import com.voidsoftware.map02.entities.Prescription; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.dto.CompanyDTO; +import com.voidsoftware.map02.models.dto.MedicationDTO; +import com.voidsoftware.map02.models.dto.PatientDTO; +import com.voidsoftware.map02.models.dto.ProfessionalDTO; +import com.voidsoftware.map02.models.inout.*; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +class MedMapClientTest { + + + MedMapClient client; + + + void setUp() throws Exception { + + } + + @Test + void createPatient_fullChain_returnsCreatedPatient() throws Exception { + client = new MedMapClient("http://localhost:" + 8080); + + // Register an admin account and log in — session cookie stored automatically + try { + client.signup("admin@test.pt", "password123", Role.ADMIN); + } catch (MedMapClient.MedMapClientException e) { + // account may already exist in a shared context; just log in + client.login("admin@test.pt", "password123"); + } + client.login("admin@test.pt", "password123"); + // Step 1 — company + CreateCompanyRequest companyReq = new CreateCompanyRequest("Clínica Lisboa", "PT987654321", "info@clinicalx.pt", null); + + var company = client.createCompany(companyReq); + //assertThat(company.getId()).isNotNull(); + + // Step 2 — professional + + + CreateProfessionalRequest proReq = new CreateProfessionalRequest(); + proReq.setName("Dr. Carlos Almeida"); + proReq.setLicenseNumber("OM-999001"); + proReq.setCompanyId(company.companyId()); + proReq.setEmail("prof@test.pt"); + var professional = client.createProfessional(proReq); + // assertThat(professional.getId()).isNotNull(); + + // Step 3 — patient + CreatePatientRequest patReq = new CreatePatientRequest(); + patReq.setName("Sofia Martins"); + patReq.setDateOfBirth(LocalDate.of(1990, 3, 22)); + patReq.setTimezone("Europe/Lisbon"); + patReq.setActive(true); + patReq.setProfessionalId(professional.professionalId()); + var patient = client.createPatient(patReq); + + // assertThat(patient.getId()).isNotNull(); + // assertThat(patient.getName()).isEqualTo("Sofia Martins"); + // assertThat(patient.getTimezone()).isEqualTo("Europe/Lisbon"); + } + + @Test + void createPatient_unknownProfessional_throwsClientException() throws IOException, InterruptedException { + CreatePatientRequest patReq = new CreatePatientRequest(); + patReq.setName("Ghost Patient"); + patReq.setTimezone("Europe/Lisbon"); + patReq.setProfessionalId(java.util.UUID.randomUUID()); + client.createPatient(patReq); + } + + @Test + void listCompaniesWithProfessionalsAndPatients() throws Exception { + client = new MedMapClient("http://localhost:" + 8080); + client.login("admin@test.pt", "password123"); + + ListOfCompaniesResponse companies = client.listCompanies(); + System.out.println("Companies: " + companies.companies().size()); + ListOfProfessionalsResponse professionals; + for (CompanyDTO company : companies.companies()) { + System.out.printf(" Company: %s (%s)%n", company.name(), company.companyId()); + + ListProfessionalsRequest profReq = new ListProfessionalsRequest(); + profReq.setCompanyId(company.companyId()); + professionals = client.listProfessionals(profReq); + System.out.printf(" Professionals: %d%n", professionals.professionals().size()); + ListOfPatientsResponse patients = null; + for (ProfessionalDTO professional : professionals.professionals()) { + System.out.printf(" Professional: %s (%s)%n", + professional.name(), professional.professionalId()); + + ListPatientsRequest patReq = new ListPatientsRequest(); + patReq.setProfessionalId(professional.professionalId()); + patients = client.listPatients(patReq); + System.out.printf(" Patients: %d%n", patients.patients().size()); + + for (PatientDTO patient : patients.patients()) { + System.out.printf(" Patient: %s (%s)%n", + patient.name(), patient.id()); + } + } + ListOfMedicationsResponse meds = client.listMedications(); + for (MedicationDTO med : meds.medications()) { + System.out.printf(" Med: %s (%s)%n", + med.name(), med.medicationId()); + + } + + assert patients != null; + UUID patientId = patients.patients().get(0).id(); + UUID prescriberId = professionals.professionals().get(0).professionalId(); + UUID medicationId = meds.medications().get(0).medicationId(); + + for (FrequencyType frequencyType : FrequencyType.values()) { + CreatePrescriptionRequest req = buildPrescriptionRequest(frequencyType, patientId, prescriberId, medicationId); + Prescription prescription = client.createPrescription(req); + assertNotNull(prescription.getId()); + } + } + } + + @Test + void createPrescription_allFrequencyTypes_returnsCreatedPrescription() throws Exception { + client = new MedMapClient("http://localhost:" + 8080); + client.login("admin@test.pt", "password123"); + + ListOfCompaniesResponse companies = client.listCompanies(); + CompanyDTO company = companies.companies().get(0); + + ListProfessionalsRequest profReq = new ListProfessionalsRequest(); + profReq.setCompanyId(company.companyId()); + ListOfProfessionalsResponse professionals = client.listProfessionals(profReq); + UUID prescriberId = professionals.professionals().get(0).professionalId(); + + ListPatientsRequest patReq = new ListPatientsRequest(); + patReq.setProfessionalId(prescriberId); + ListOfPatientsResponse patients = client.listPatients(patReq); + UUID patientId = patients.patients().get(0).id(); + + ListOfMedicationsResponse meds = client.listMedications(); + UUID medicationId = meds.medications().get(0).medicationId(); + + for (FrequencyType frequencyType : FrequencyType.values()) { + CreatePrescriptionRequest req = buildPrescriptionRequest(frequencyType, patientId, prescriberId, medicationId); + Prescription prescription = client.createPrescription(req); + + assertNotNull(prescription.getId()); + assertEquals("ACTIVE", prescription.getStatus()); + } + } + + @Test + void generateDoses_forExistingPrescription_returnsGeneratedDoses() throws Exception { + client = new MedMapClient("http://localhost:" + 8080); + client.login("admin@test.pt", "password123"); + + ListOfCompaniesResponse companies = client.listCompanies(); + CompanyDTO company = companies.companies().get(0); + + ListProfessionalsRequest profReq = new ListProfessionalsRequest(); + profReq.setCompanyId(company.companyId()); + ListOfProfessionalsResponse professionals = client.listProfessionals(profReq); + UUID prescriberId = professionals.professionals().get(0).professionalId(); + + ListPatientsRequest patReq = new ListPatientsRequest(); + patReq.setProfessionalId(prescriberId); + ListOfPatientsResponse patients = client.listPatients(patReq); + UUID patientId = patients.patients().get(0).id(); + + /* + ListOfMedicationsResponse meds = client.listMedications(); + UUID medicationId = meds.medications().get(0).medicationId(); + + CreatePrescriptionRequest createReq = buildPrescriptionRequest(FrequencyType.TWICE_DAILY, patientId, prescriberId, medicationId); + Prescription prescription = client.createPrescription(createReq); +*/ + GenerateDosesRequest genReq = new GenerateDosesRequest(); + // genReq.setPrescriptionId(prescription.getId()); + genReq.setFromDate(LocalDate.now()); + genReq.setToDate(LocalDate.now().plusDays(5)); + + List doses = client.generateDosesAll(genReq); + + assertNotNull(doses); + assertEquals(4, doses.size()); + } + + /** + * Builds a valid {@link CreatePrescriptionRequest} for the given frequency, filling in + * whichever fields (timesOfDay, intervalValue/Unit, prn) that frequency requires. + */ + private CreatePrescriptionRequest buildPrescriptionRequest(FrequencyType frequencyType, + UUID patientId, + UUID prescriberId, + UUID medicationId) { + CreatePrescriptionRequest req = new CreatePrescriptionRequest(); + req.setPatientId(patientId); + req.setPrescriberId(prescriberId); + req.setMedicationId(medicationId); + req.setStartDate(LocalDate.now()); + req.setEndDate(LocalDate.now().plusDays(30)); + req.setTimezone("Europe/Lisbon"); + req.setDoseAmount(BigDecimal.valueOf(1)); + req.setDoseUnit("tablet"); + req.setInstructions("Test instructions for " + frequencyType); + req.setFrequencyType(frequencyType); + + switch (frequencyType) { + case ONCE_DAILY -> req.setTimesOfDay(List.of("08:00")); + case TWICE_DAILY -> req.setTimesOfDay(List.of("08:00", "20:00")); + case THREE_TIMES_DAILY -> req.setTimesOfDay(List.of("08:00", "14:00", "20:00")); + case FOUR_TIMES_DAILY -> req.setTimesOfDay(List.of("06:00", "12:00", "18:00", "00:00")); + case FIXED_TIMES -> req.setTimesOfDay(List.of("09:00", "21:00")); + case INTERVAL -> { + req.setIntervalValue(8); + req.setIntervalUnit("hours"); + } + case ALTERNATE_DAYS, WEEKLY -> { + // no additional fields required beyond the base request + } + case AS_NEEDED -> req.setPrn(true); + } + + return req; + } +} diff --git a/src/test/java/com/voidsoftware/map02/config/AdminBoardSecurityTest.java b/src/test/java/com/voidsoftware/map02/config/AdminBoardSecurityTest.java new file mode 100644 index 0000000..6ca4646 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/config/AdminBoardSecurityTest.java @@ -0,0 +1,299 @@ +package com.voidsoftware.map02.config; + +import com.voidsoftware.map02.controllers.AdminBoardController; +import com.voidsoftware.map02.controllers.AdminDoseController; +import com.voidsoftware.map02.services.DoseGenerationService; +import com.voidsoftware.map02.controllers.AdminUserController; +import com.voidsoftware.map02.controllers.AdminPatientController; +import com.voidsoftware.map02.services.SeedDataService; +import com.voidsoftware.map02.repositories.AppUserRepository; +import com.voidsoftware.map02.services.AdminUserManagementService; +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.services.AdminBoardService; +import com.voidsoftware.map02.services.AppUserDetailsService; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.*; +import org.springframework.mock.web.MockServletContext; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** Real security chains and controller; no DB, seeders or scheduled jobs are started. */ +class AdminBoardSecurityTest { + private AnnotationConfigWebApplicationContext context; + private MockMvc mvc; + + @BeforeEach + void setUp() { + context = new AnnotationConfigWebApplicationContext(); + context.setServletContext(new MockServletContext()); + context.register(TestConfig.class); + context.refresh(); + mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + @AfterEach + void tearDown() { context.close(); } + + @Test + void anonymousUsersMustLogIn() throws Exception { + mvc.perform(get("/admin/dashboard")).andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + verifyNoInteractions(context.getBean(AdminBoardService.class)); + } + + @Test + void professionalsAndPatientsCannotReadAdminData() throws Exception { + for (String role : new String[]{"PROFESSIONAL", "PATIENT"}) { + mvc.perform(get("/admin/dashboard").with(user("user@example.test").roles(role))).andExpect(status().isForbidden()); + } + verifyNoInteractions(context.getBean(AdminBoardService.class)); + } + + @Test + void administratorCanOpenBoardWithoutProfessionalProfile() throws Exception { + mvc.perform(get("/admin/dashboard").with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isOk()).andExpect(view().name("admin-dashboard")) + .andExpect(model().attribute("adminName", "admin@example.test")); + verify(context.getBean(AdminBoardService.class)).build("users", "", 0); + } + + @Test + void formLoginRoutesByRoleAndRejectsDisabledAccounts() throws Exception { + AppUserDetailsService details = context.getBean(AppUserDetailsService.class); + PasswordEncoder encoder = context.getBean(PasswordEncoder.class); + for (Role role : Role.values()) { + AppUser account = new AppUser(); + account.setEmail(role.name().toLowerCase() + "@example.test"); + account.setRole(role); + account.setPassword(encoder.encode("test-password")); + when(details.loadUserByUsername(account.getEmail())).thenReturn(account); + mvc.perform(formLogin().user("email", account.getEmail()).password("test-password")) + .andExpect(redirectedUrl(role == Role.ADMIN ? "/admin/dashboard" : "/dashboard")); + account.setEnabled(false); + mvc.perform(formLogin().user("email", account.getEmail()).password("test-password")) + .andExpect(redirectedUrl("/login?error")); + } + } + + @Configuration + @EnableWebMvc + @Import({SecurityConfig.class, AdminBoardController.class, AdminUserController.class, AdminPatientController.class, AdminDoseController.class}) + static class TestConfig { + @Bean DoseGenerationService doseGenerationService() { return mock(DoseGenerationService.class); } + final SeedDataService seedMock = mock(SeedDataService.class); + @Bean SeedDataService seedDataService() { return seedMock; } + @Bean AppUserDetailsService userDetailsService() { return mock(AppUserDetailsService.class); } + @Bean AdminBoardService adminBoardService() { return mock(AdminBoardService.class); } + @Bean AppUserRepository appUserRepository() { return mock(AppUserRepository.class); } + @Bean AdminUserManagementService adminUserManagementService() { return mock(AdminUserManagementService.class); } + } + + @Test + void logoutClearsAuthenticationSessionAndCookieForAllRoles() throws Exception { + for (Role role : Role.values()) { + MockHttpSession session = new MockHttpSession(context.getServletContext()); + mvc.perform(post("/logout").session(session) + .with(user("user@example.test").roles(role.name())).with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?logout")) + .andExpect(cookie().maxAge("JSESSIONID", 0)) + .andExpect(unauthenticated()); + assertTrue(session.isInvalid()); + } + } + + @Test + void logoutRequiresCsrfToken() throws Exception { + mvc.perform(post("/logout").with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isForbidden()); + } + + @Test + void globalDoseGenerationRequiresAdminCsrfAndPost() throws Exception { + String url = "/admin/doses/generate"; + mvc.perform(post(url).param("daysAhead", "7").param("confirmed", "true").with(csrf())) + .andExpect(redirectedUrl("http://localhost/login")); + for (String role : new String[]{"PROFESSIONAL", "PATIENT"}) { + mvc.perform(post(url).param("daysAhead", "7").param("confirmed", "true") + .with(user("user@example.test").roles(role)).with(csrf())) + .andExpect(status().isForbidden()); + } + mvc.perform(post(url).param("daysAhead", "7").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isForbidden()); + mvc.perform(get(url).with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isMethodNotAllowed()); + verifyNoInteractions(context.getBean(DoseGenerationService.class)); + } + + @Test + void invalidDoseWindowsAndMissingConfirmationDoNotGenerate() throws Exception { + for (String days : new String[]{"", "-1", "91", "2147483648", "1.5", "abc"}) { + mvc.perform(post("/admin/doses/generate").param("daysAhead", days).param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminError")); + } + mvc.perform(post("/admin/doses/generate").param("daysAhead", "7") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminError")); + verifyNoInteractions(context.getBean(DoseGenerationService.class)); + } + + @Test + void generatesInclusiveWindowAndPreservesDirectoryWithoutDailyMarker() throws Exception { + DoseGenerationService doses = context.getBean(DoseGenerationService.class); + for (int days : new int[]{0, 7, 90}) { + clearInvocations(doses); + when(doses.generateDosesForAllPatients(any(), any())).thenReturn(java.util.List.of(new com.voidsoftware.map02.entities.MedicationDose())); + java.time.LocalDate earliest = java.time.LocalDate.now(); + mvc.perform(post("/admin/doses/generate").param("daysAhead", Integer.toString(days)).param("confirmed", "true") + .param("tab", "patients").param("q", "Ana").param("page", "2") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(redirectedUrl("/admin/dashboard?tab=patients&q=Ana&page=2")) + .andExpect(flash().attribute("doseDaysAhead", days)) + .andExpect(flash().attribute("adminSuccess", org.hamcrest.Matchers.containsString("1 novas doses criadas"))); + var from = org.mockito.ArgumentCaptor.forClass(java.time.LocalDate.class); + var to = org.mockito.ArgumentCaptor.forClass(java.time.LocalDate.class); + verify(doses).generateDosesForAllPatients(from.capture(), to.capture()); + assertTrue(!from.getValue().isBefore(earliest) && !from.getValue().isAfter(java.time.LocalDate.now())); + org.junit.jupiter.api.Assertions.assertEquals(from.getValue().plusDays(days), to.getValue()); + verify(doses, never()).runDailyGenerationIfNeeded(any()); + } + } + + @Test + void doseGenerationReportsEmptyResultAndFailureSafely() throws Exception { + DoseGenerationService doses = context.getBean(DoseGenerationService.class); + when(doses.generateDosesForAllPatients(any(), any())).thenReturn(java.util.List.of()); + mvc.perform(post("/admin/doses/generate").param("daysAhead", "0").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(flash().attribute("adminSuccess", org.hamcrest.Matchers.containsString("0 novas doses criadas"))); + when(doses.generateDosesForAllPatients(any(), any())).thenThrow(new IllegalStateException("internal details")); + mvc.perform(post("/admin/doses/generate").param("daysAhead", "7").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(flash().attribute("adminError", "Não foi possível concluir a geração de doses. Consulte os registos antes de repetir a operação.")) + .andExpect(flash().attributeCount(2)); + } + + @Test + void prescriptionGenerationRequiresAdminAndCsrf() throws Exception { + String url = "/admin/patients/" + java.util.UUID.randomUUID() + "/prescriptions/generate"; + mvc.perform(post(url).param("count", "3").param("confirmed", "true").with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("http://localhost/login")); + for (String role : new String[]{"PROFESSIONAL", "PATIENT"}) { + mvc.perform(post(url).param("count", "3").param("confirmed", "true") + .with(user("user@example.test").roles(role)).with(csrf())) + .andExpect(status().isForbidden()); + } + mvc.perform(post(url).param("count", "3").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isForbidden()); + verifyNoInteractions(context.getBean(TestConfig.class).seedMock); + } + + @Test + void prescriptionGenerationRequiresConfirmationAndNumericQuantity() throws Exception { + String url = "/admin/patients/" + java.util.UUID.randomUUID() + "/prescriptions/generate"; + mvc.perform(post(url).param("count", "3").with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminError")); + mvc.perform(post(url).param("count", "not-a-number").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().isBadRequest()); + verifyNoInteractions(context.getBean(TestConfig.class).seedMock); + } + + @Test + void prescriptionGenerationPreservesDirectoryContextAndShowsFeedback() throws Exception { + AppUser admin = new AppUser(); + admin.setId(java.util.UUID.randomUUID()); + admin.setEmail("admin@example.test"); + admin.setRole(Role.ADMIN); + when(context.getBean(AppUserRepository.class).existsByIdAndEnabledTrue(admin.getId())).thenReturn(true); + java.util.UUID patientId = java.util.UUID.randomUUID(); + SeedDataService seeds = context.getBean(TestConfig.class).seedMock; + when(seeds.generatePrescriptions(admin, patientId, 2)).thenReturn(java.util.List.of( + new com.voidsoftware.map02.entities.Prescription(), new com.voidsoftware.map02.entities.Prescription())); + String url = "/admin/patients/" + patientId + "/prescriptions/generate"; + mvc.perform(post(url).param("count", "2").param("confirmed", "true") + .param("tab", "companies").param("q", "Lisboa").param("page", "2") + .with(user(admin)).with(csrf())) + .andExpect(redirectedUrl("/admin/dashboard?tab=companies&q=Lisboa&page=2")) + .andExpect(flash().attribute("adminSuccess", "2 prescrições de teste criadas com os respetivos horários.")); + verify(seeds).generatePrescriptions(admin, patientId, 2); + + when(seeds.generatePrescriptions(admin, patientId, 51)).thenThrow(new IllegalArgumentException("Quantidade inválida.")); + mvc.perform(post(url).param("count", "51").param("confirmed", "true") + .param("tab", "invalid").param("page", "-1").with(user(admin)).with(csrf())) + .andExpect(redirectedUrl("/admin/dashboard?tab=patients&q=&page=0")) + .andExpect(flash().attribute("adminError", "Quantidade inválida.")); + } + + @Test + void accountActionsRequireAdminAndCsrf() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + for (String action : new String[]{"status", "delete"}) { + mvc.perform(post("/admin/users/" + id + "/" + action).param("enabled", "false").param("confirmed", "true") + .with(user("professional@example.test").roles("PROFESSIONAL")).with(csrf())) + .andExpect(status().isForbidden()); + mvc.perform(post("/admin/users/" + id + "/" + action).param("enabled", "false").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isForbidden()); + } + verifyNoInteractions(context.getBean(AdminUserManagementService.class)); + } + + @Test + void deletionRequiresExplicitConfirmation() throws Exception { + mvc.perform(post("/admin/users/" + java.util.UUID.randomUUID() + "/delete") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminError")); + verifyNoInteractions(context.getBean(AdminUserManagementService.class)); + } + + @Test + void adminCanChangeStatusAndDeleteConfirmedAccount() throws Exception { + java.util.UUID id = java.util.UUID.randomUUID(); + mvc.perform(post("/admin/users/" + id + "/status").param("enabled", "false") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminSuccess")); + verify(context.getBean(AdminUserManagementService.class)).setEnabled(id, false, "admin@example.test"); + mvc.perform(post("/admin/users/" + id + "/delete").param("confirmed", "true") + .with(user("admin@example.test").roles("ADMIN")).with(csrf())) + .andExpect(status().is3xxRedirection()).andExpect(flash().attributeExists("adminSuccess")); + verify(context.getBean(AdminUserManagementService.class)).delete(id, "admin@example.test"); + } + + @Test + void disabledOrDeletedAccountsLoseExistingWebAndApiAccess() throws Exception { + AppUser account = new AppUser(); + account.setId(java.util.UUID.randomUUID()); + account.setEmail("admin@example.test"); + account.setRole(Role.ADMIN); + account.setPassword("unused"); + when(context.getBean(AppUserRepository.class).existsByIdAndEnabledTrue(account.getId())).thenReturn(false); + MockHttpSession session = new MockHttpSession(context.getServletContext()); + mvc.perform(get("/admin/dashboard").session(session).with(user(account))) + .andExpect(status().is3xxRedirection()).andExpect(unauthenticated()); + assertTrue(session.isInvalid()); + mvc.perform(get("/api/companies").with(user(account))) + .andExpect(status().isUnauthorized()).andExpect(unauthenticated()); + verifyNoInteractions(context.getBean(AdminBoardService.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/entities/EntityIndexMappingTest.java b/src/test/java/com/voidsoftware/map02/entities/EntityIndexMappingTest.java new file mode 100644 index 0000000..df0b5b4 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/entities/EntityIndexMappingTest.java @@ -0,0 +1,90 @@ +package com.voidsoftware.map02.entities; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** Annotation-level regression checks; these do not replace database query-plan tests. */ +class EntityIndexMappingTest { + static Stream> entities() { + return Stream.of(AppUser.class, Company.class, DoseEvent.class, DoseGenerationRun.class, + MediaResource.class, Medication.class, MedicationAlert.class, MedicationDose.class, + MedicationInventory.class, NotificationPreference.class, Patient.class, + PatientPeriods.class, Prescription.class, PrescriptionSchedule.class, Professional.class); + } + + @ParameterizedTest + @MethodSource("entities") + void tableIndexesReferenceMappedColumns(Class entity) { + assertTrue(entity.isAnnotationPresent(Entity.class)); + Set columns = Arrays.stream(entity.getDeclaredFields()) + .filter(f -> !f.isAnnotationPresent(CollectionTable.class)) + .map(EntityIndexMappingTest::columnName).collect(Collectors.toSet()); + for (Index index : entity.getAnnotation(Table.class).indexes()) { + for (String part : index.columnList().split(",")) { + String column = part.trim().split("\\s+")[0]; + assertTrue(columns.contains(column), () -> index.name() + " references unmapped column " + column); + } + } + } + + @Test + void indexNamesAreUniqueAcrossTablesAndCollections() { + Set names = new HashSet<>(); + entities().forEach(entity -> { + for (Index index : entity.getAnnotation(Table.class).indexes()) { + assertTrue(names.add(index.name()), () -> "Duplicate index name: " + index.name()); + } + for (Field field : entity.getDeclaredFields()) { + CollectionTable table = field.getAnnotation(CollectionTable.class); + if (table != null) { + for (Index index : table.indexes()) { + assertTrue(names.add(index.name()), () -> "Duplicate collection index: " + index.name()); + } + } + } + }); + } + + @Test + void doseHistoryUsesExistingUniqueIndexWithoutDuplicatePrescriptionIndex() { + Table table = MedicationDose.class.getAnnotation(Table.class); + assertTrue(Arrays.stream(table.uniqueConstraints()).anyMatch(c -> + Arrays.equals(c.columnNames(), new String[]{"prescription_id", "scheduled_at"}))); + assertFalse(Arrays.stream(table.indexes()).anyMatch(i -> + i.columnList().replace(" ", "").equals("prescription_id") + || i.columnList().replace(" ", "").equals("prescription_id,scheduled_at"))); + } + + @Test + void scheduleTimesHaveAnIndexedOwnerColumn() throws Exception { + CollectionTable table = PrescriptionSchedule.class.getDeclaredField("timesOfDay") + .getAnnotation(CollectionTable.class); + assertEquals("schedule_id", table.joinColumns()[0].name()); + assertTrue(Arrays.stream(table.indexes()).anyMatch(i -> i.columnList().equals("schedule_id"))); + } + + private static String columnName(Field field) { + JoinColumn join = field.getAnnotation(JoinColumn.class); + if (join != null && !join.name().isEmpty()) return join.name(); + Column column = field.getAnnotation(Column.class); + if (column != null && !column.name().isEmpty()) return column.name(); + // Default Spring Boot naming for the simple camelCase fields used by these entities. + return field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/AdminBoardServiceTest.java b/src/test/java/com/voidsoftware/map02/services/AdminBoardServiceTest.java new file mode 100644 index 0000000..da51046 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/AdminBoardServiceTest.java @@ -0,0 +1,100 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.view.AdminUserView; +import com.voidsoftware.map02.repositories.*; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.*; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AdminBoardServiceTest { + private final AppUserRepository users = mock(AppUserRepository.class); + private final CompanyRepository companies = mock(CompanyRepository.class); + private final ProfessionalRepository professionals = mock(ProfessionalRepository.class); + private final PatientRepository patients = mock(PatientRepository.class); + private final AdminBoardService service = new AdminBoardService(users, companies, professionals, patients); + + @Test + void companyHierarchyKeepsPatientsUnderTheirOwnProfessional() { + Company company = new Company(); + company.setId(UUID.randomUUID()); + company.setName("Clínica Lisboa"); + Professional first = professional(company, "Ana"); + Professional second = professional(company, "Bruno"); + Patient patient = new Patient(); + patient.setId(UUID.randomUUID()); + patient.setName("Cristina"); + patient.setProfessional(second); + when(companies.findByNameContainingIgnoreCase(eq(""), any())).thenReturn(new PageImpl<>(List.of(company))); + when(professionals.findByCompanyIdInOrderByNameAsc(List.of(company.getId()))).thenReturn(List.of(first, second)); + when(patients.findByProfessionalIdInOrderByNameAsc(List.of(first.getId(), second.getId()))).thenReturn(List.of(patient)); + + var board = service.build("companies", "", 0); + var result = board.getCompanies().getContent().getFirst(); + assertEquals(2, result.getProfessionals().size()); + assertTrue(result.getProfessionals().getFirst().getPatients().isEmpty()); + assertEquals("Cristina", result.getProfessionals().get(1).getPatients().getFirst().getName()); + assertEquals(1, result.getPatientCount()); + verify(patients, times(1)).findByProfessionalIdInOrderByNameAsc(any()); + verify(professionals, never()).findByCompanyId(any()); + } + + @Test + void companiesWithNoStaffRemainVisibleAndDoNotQueryPatients() { + Company company = new Company(); + company.setId(UUID.randomUUID()); + when(companies.findByNameContainingIgnoreCase(eq(""), any())).thenReturn(new PageImpl<>(List.of(company))); + var result = service.build("companies", "", 0).getCompanies().getContent().getFirst(); + assertTrue(result.getProfessionals().isEmpty()); + assertEquals(0, result.getPatientCount()); + verify(patients, never()).findByProfessionalIdInOrderByNameAsc(any()); + } + + @Test + void patientListIncludesUnassignedAndInactivePatients() { + Patient patient = new Patient(); + patient.setId(UUID.randomUUID()); + patient.setName("Sem atribuição"); + patient.setActive(false); + when(patients.findByNameContainingIgnoreCase(eq("Sem"), any())).thenReturn(new PageImpl<>(List.of(patient))); + var result = service.build("patients", " Sem ", 0).getPatients().getContent().getFirst(); + assertEquals("Não atribuído", result.getProfessionalName()); + assertEquals("Sem empresa", result.getCompanyName()); + assertFalse(result.isActive()); + } + + @Test + void invalidTabAndNegativePageUseFirstUserPageWithoutLoadingEntities() { + when(users.findAdminUsers(eq("admin"), any())).thenReturn(Page.empty()); + var result = service.build("invalid", " admin ", -2); + assertEquals("users", result.getTab()); + assertEquals("admin", result.getQuery()); + verify(users).findAdminUsers(eq("admin"), argThat(p -> p.getPageNumber() == 0 && p.getPageSize() == 20)); + verify(users, never()).findAll(); + assertThrows(NoSuchMethodException.class, () -> AdminUserView.class.getMethod("getPassword")); + } + + @Test + void stalePaginationIsClampedToLastPage() { + when(users.findAdminUsers(eq(""), any())).thenAnswer(invocation -> { + Pageable pageable = invocation.getArgument(1); + return new PageImpl(List.of(), pageable, 21); + }); + service.build("users", "", 100); + verify(users).findAdminUsers(eq(""), argThat(p -> p.getPageNumber() == 1)); + } + + private Professional professional(Company company, String name) { + Professional professional = new Professional(); + professional.setId(UUID.randomUUID()); + professional.setName(name); + professional.setCompany(company); + return professional; + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/AdminBoardTemplateTest.java b/src/test/java/com/voidsoftware/map02/services/AdminBoardTemplateTest.java new file mode 100644 index 0000000..d77f757 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/AdminBoardTemplateTest.java @@ -0,0 +1,100 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.models.view.AdminBoardView; +import com.voidsoftware.map02.models.view.AdminBoardView.*; +import com.voidsoftware.map02.models.view.AdminUserView; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.domain.*; +import org.springframework.mock.web.*; +import org.thymeleaf.context.WebContext; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; +import org.thymeleaf.web.servlet.JakartaServletWebApplication; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class AdminBoardTemplateTest { + @ParameterizedTest + @ValueSource(strings = {"users", "companies", "professionals", "patients"}) + void rendersEachDirectoryAndNestedFragments(String tab) { + PatientItem patient = new PatientItem(UUID.randomUUID(), "Paciente ", + null, null, false, "Dra. Ana", "Clínica Lisboa"); + ProfessionalItem professional = new ProfessionalItem(UUID.randomUUID(), "Dra. Ana", "ana@example.test", + "123", "Clínica Lisboa", List.of(patient)); + CompanyItem company = new CompanyItem(UUID.randomUUID(), "Clínica Lisboa", "123456789", null, List.of(professional)); + var board = new AdminBoardView(tab, "", 21, 1, 1, 1, + new PageImpl<>(List.of(new AdminUserView(UUID.randomUUID(), "admin@example.test", Role.ADMIN, + true, Instant.parse("2026-09-09T12:00:00Z"), null), + new AdminUserView(UUID.randomUUID(), "active@example.test", Role.PROFESSIONAL, true, null, null), + new AdminUserView(UUID.randomUUID(), "inactive@example.test", Role.PATIENT, false, null, null)), PageRequest.of(0, 20), 21), + new PageImpl<>(List.of(company)), new PageImpl<>(List.of(professional)), new PageImpl<>(List.of(patient))); + + String html = render(board); + assertTrue(html.contains("dashboardlogo.svg")); + assertTrue(html.contains("Gestão da plataforma")); + assertTrue(html.contains("/admin/doses/generate")); + assertTrue(html.contains("name=\"daysAhead\"")); + assertTrue(html.contains("max=\"90\"")); + assertTrue(html.contains("0 = só hoje")); + assertTrue(html.contains("Confirmo a geração de doses para todos os pacientes.")); + assertFalse(html.contains("th:each")); + assertFalse(html.contains("${board")); + assertFalse(html.contains("")); + if (tab.equals("users")) { + assertTrue(html.contains("09/09/2026 12:00")); + assertTrue(html.contains("Sem perfil associado")); + assertTrue(html.contains("Seguinte")); + assertTrue(html.contains("A sua conta")); + assertTrue(html.contains("Desativar active@example.test")); + assertTrue(html.contains("Ativar inactive@example.test")); + assertTrue(html.contains("Confirmo a eliminação desta conta.")); + assertFalse(html.contains("Eliminar conta de admin@example.test")); + } else { + assertTrue(html.contains("Paciente <script>")); + assertTrue(html.contains("Clínica Lisboa")); + assertTrue(html.contains("Inativo")); + assertTrue(html.contains("/admin/patients/" + patient.getId() + "/prescriptions/generate")); + assertTrue(html.contains("+ Gerar prescrições")); + assertTrue(html.contains("name=\"count\"")); + assertTrue(html.contains("max=\"50\"")); + assertTrue(html.contains("Confirmo a criação de dados de teste para este paciente.")); + assertTrue(html.contains("Não utilizar em cuidados reais.")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"users", "companies", "professionals", "patients"}) + void rendersEmptyDirectories(String tab) { + String html = render(new AdminBoardView(tab, "", 0, 0, 0, 0, + Page.empty(), Page.empty(), Page.empty(), Page.empty())); + assertTrue(html.contains("Sem resultados")); + assertTrue(html.contains("Ainda não existem registos nesta lista.")); + assertTrue(html.contains("/admin/doses/generate")); + } + + private String render(AdminBoardView board) { + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix("templates/"); + resolver.setSuffix(".html"); + resolver.setTemplateMode("HTML"); + resolver.setCharacterEncoding("UTF-8"); + SpringTemplateEngine engine = new SpringTemplateEngine(); + engine.setTemplateResolver(resolver); + MockServletContext servletContext = new MockServletContext(); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setRequestURI("/admin/dashboard"); + WebContext context = new WebContext(JakartaServletWebApplication.buildApplication(servletContext) + .buildExchange(request, new MockHttpServletResponse()), Locale.forLanguageTag("pt-PT")); + context.setVariable("board", board); + context.setVariable("pageTitle", "Administração"); + context.setVariable("adminName", "admin@example.test"); + return engine.process("admin-dashboard", context); + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/AdminUserManagementServiceTest.java b/src/test/java/com/voidsoftware/map02/services/AdminUserManagementServiceTest.java new file mode 100644 index 0000000..0b27318 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/AdminUserManagementServiceTest.java @@ -0,0 +1,98 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.AppUser; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.repositories.*; +import org.junit.jupiter.api.*; +import org.springframework.security.access.AccessDeniedException; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AdminUserManagementServiceTest { + private final AppUserRepository users = mock(AppUserRepository.class); + private final PatientRepository patients = mock(PatientRepository.class); + private final ProfessionalRepository professionals = mock(ProfessionalRepository.class); + private final AdminUserManagementService service = new AdminUserManagementService(users, patients, professionals); + private AppUser admin; + private AppUser target; + + @BeforeEach + void setUp() { + admin = account(Role.ADMIN, "admin@example.test"); + target = account(Role.PATIENT, "patient@example.test"); + when(users.lockByRole(Role.ADMIN)).thenReturn(List.of(admin)); + when(users.findForUpdate(target.getId())).thenReturn(Optional.of(target)); + when(users.findForUpdate(admin.getId())).thenReturn(Optional.of(admin)); + } + + @Test + void activationAndDeactivationDoNotChangeProfiles() { + service.setEnabled(target.getId(), false, admin.getEmail()); + assertFalse(target.isEnabled()); + service.setEnabled(target.getId(), true, admin.getEmail()); + assertTrue(target.isEnabled()); + verify(users, times(2)).save(target); + verifyNoInteractions(patients, professionals); + } + + @Test + void deletionUnlinksProfilesBeforeRemovingOnlyTheAccount() { + service.delete(target.getId(), admin.getEmail()); + var order = inOrder(users, patients, professionals); + order.verify(patients).unlinkAppUser(target.getId()); + order.verify(professionals).unlinkAppUser(target.getId()); + order.verify(users).delete(target); + order.verify(users).flush(); + verify(patients, never()).deleteById(any()); + verify(professionals, never()).deleteById(any()); + } + + @Test + void lastActiveAdminCannotBeRemovedOrDisabled() { + assertThrows(IllegalArgumentException.class, () -> service.delete(admin.getId(), admin.getEmail())); + assertThrows(IllegalArgumentException.class, () -> service.setEnabled(admin.getId(), false, admin.getEmail())); + verify(users, never()).delete(any()); + verify(users, never()).save(any()); + } + + @Test + void ownAccountIsProtectedEvenWithOtherAdmins() { + when(users.lockByRole(Role.ADMIN)).thenReturn(List.of(admin, account(Role.ADMIN, "other@example.test"))); + assertThrows(IllegalArgumentException.class, () -> service.delete(admin.getId(), admin.getEmail())); + assertThrows(IllegalArgumentException.class, () -> service.setEnabled(admin.getId(), false, admin.getEmail())); + verifyNoInteractions(patients, professionals); + } + + @Test + void disabledAdministratorCannotMutateUsingStaleSession() { + admin.setEnabled(false); + assertThrows(AccessDeniedException.class, () -> service.delete(target.getId(), admin.getEmail())); + verify(users, never()).delete(any()); + } + + @Test + void missingAccountFailsWithoutSideEffects() { + when(users.findForUpdate(target.getId())).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> service.delete(target.getId(), admin.getEmail())); + verifyNoInteractions(patients, professionals); + } + + @Test + void anotherAdministratorCanBeRemovedWhileActorRemainsActive() { + target.setRole(Role.ADMIN); + when(users.lockByRole(Role.ADMIN)).thenReturn(List.of(admin, target)); + service.delete(target.getId(), admin.getEmail()); + verify(users).delete(target); + assertTrue(admin.isEnabled()); + } + + private AppUser account(Role role, String email) { + AppUser account = new AppUser(); + account.setId(UUID.randomUUID()); + account.setEmail(email); + account.setRole(role); + account.setEnabled(true); + return account; + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/PatientDetailServiceTest.java b/src/test/java/com/voidsoftware/map02/services/PatientDetailServiceTest.java new file mode 100644 index 0000000..2bc187d --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/PatientDetailServiceTest.java @@ -0,0 +1,158 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.dto.PatientDoseHistoryDTO; +import com.voidsoftware.map02.models.view.PatientDetailView; +import com.voidsoftware.map02.repositories.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class PatientDetailServiceTest { + private final PatientRepository patients = mock(PatientRepository.class); + private final MedicationDoseRepository doses = mock(MedicationDoseRepository.class); + private final PrescriptionRepository prescriptions = mock(PrescriptionRepository.class); + private final PrescriptionScheduleRepository schedules = mock(PrescriptionScheduleRepository.class); + private final MedicationInventoryRepository inventories = mock(MedicationInventoryRepository.class); + private final DataConversionService images = mock(DataConversionService.class); + private final PatientDetailService service = new PatientDetailService(patients, doses, prescriptions, schedules, inventories, images); + private Patient patient; + private Professional professional; + + @BeforeEach + void setUp() { + professional = new Professional(); + professional.setId(UUID.randomUUID()); + patient = new Patient(); + patient.setId(UUID.randomUUID()); + patient.setProfessional(professional); + patient.setName("Cristina Francisco"); + patient.setTimezone("Europe/Lisbon"); + when(patients.findById(patient.getId())).thenReturn(Optional.of(patient)); + when(doses.findPatientHistory(eq(patient.getId()), any(), any())).thenReturn(List.of()); + when(prescriptions.findByPatientIdAndStatus(patient.getId(), "ACTIVE")).thenReturn(List.of()); + } + + @Test + void refusesOtherProfessionalsBeforeFetchingSensitiveData() { + Professional stranger = new Professional(); + stranger.setId(UUID.randomUUID()); + assertTrue(service.build(patient.getId(), stranger, "monthly", LocalDate.of(2026, 7, 1)).isEmpty()); + verifyNoInteractions(doses, prescriptions, schedules, inventories, images); + } + + @Test + void missingPatientReturnsEmpty() { + UUID missing = UUID.randomUUID(); + when(patients.findById(missing)).thenReturn(Optional.empty()); + assertTrue(service.build(missing, professional, "weekly", null).isEmpty()); + verifyNoInteractions(doses, prescriptions, schedules, inventories, images); + } + + @Test + void weeklyWindowIsMondayToSundayWithExclusiveEndAndDstAwareBoundaries() { + PatientDetailView view = service.build(patient.getId(), professional, "weekly", LocalDate.of(2026, 3, 29)).orElseThrow(); + verify(doses).findPatientHistory(patient.getId(), Instant.parse("2026-03-23T00:00:00Z"), Instant.parse("2026-03-29T23:00:00Z")); + assertEquals(7, view.getDays().size()); + assertEquals(LocalDate.of(2026, 3, 23), view.getDays().getFirst().getDate()); + assertEquals("SEG", view.getDays().getFirst().getWeekday()); + assertEquals("DOM", view.getDays().getLast().getWeekday()); + assertEquals(0, view.getAdherencePercent()); + assertTrue(view.getDays().stream().allMatch(d -> d.getTone().equals("neutral"))); + } + + @Test + void monthlyCalendarHandlesLeapYearsAndMondayStarts() { + PatientDetailView leap = service.build(patient.getId(), professional, "monthly", LocalDate.of(2024, 2, 15)).orElseThrow(); + assertEquals(29, leap.getDays().size()); + assertEquals(3, leap.getLeadingDays()); + assertEquals("Fevereiro 2024", leap.getPeriodLabel()); + PatientDetailView monday = service.build(patient.getId(), professional, "monthly", LocalDate.of(2026, 6, 15)).orElseThrow(); + assertEquals(0, monday.getLeadingDays()); + assertEquals(30, monday.getDays().size()); + } + + @Test + void bucketsByPatientLocalDateAndCountsEveryScheduledStateOnce() { + when(doses.findPatientHistory(eq(patient.getId()), any(), any())).thenReturn(List.of( + new PatientDoseHistoryDTO(Instant.parse("2026-07-01T23:30:00Z"), Instant.parse("2026-07-01T23:35:00Z"), DoseTakenState.TAKEN, "A"), + new PatientDoseHistoryDTO(Instant.parse("2026-07-02T09:00:00Z"), null, DoseTakenState.PENDING, "A"), + new PatientDoseHistoryDTO(Instant.parse("2026-07-02T10:00:00Z"), null, DoseTakenState.SKIPPED, "B"), + new PatientDoseHistoryDTO(Instant.parse("2026-07-02T11:00:00Z"), null, DoseTakenState.SNOOZED, "B"))); + PatientDetailView view = service.build(patient.getId(), professional, "monthly", LocalDate.of(2026, 7, 12)).orElseThrow(); + assertEquals(0, view.getDays().getFirst().getTotal()); + assertEquals(4, view.getDays().get(1).getTotal()); + assertEquals(1, view.getDays().get(1).getTaken()); + assertEquals(25, view.getDays().get(1).getPercent()); + assertEquals("bad", view.getDays().get(1).getTone()); + assertEquals(25, view.getAdherencePercent()); + assertEquals("02/07/2026", view.getHistory().getFirst().getDate()); + assertEquals("00:30", view.getHistory().getFirst().getScheduledTime()); + verify(doses).findPatientHistory(patient.getId(), Instant.parse("2026-06-30T23:00:00Z"), Instant.parse("2026-07-31T23:00:00Z")); + } + + @Test + void prescriptionCardsUseRealDoseAmountClockTimesAndStockThreshold() { + Medication medication = new Medication(); + medication.setName("Medicamento A"); + medication.setStrength("200 mg"); + Prescription prescription = new Prescription(); + prescription.setId(UUID.randomUUID()); + prescription.setMedication(medication); + PrescriptionSchedule schedule = new PrescriptionSchedule(); + schedule.setPrescription(prescription); + schedule.setFrequencyType(FrequencyType.FIXED_TIMES); + schedule.setDoseAmount(new BigDecimal("1.0000")); + schedule.setDoseUnit("unidades"); + schedule.setTimesOfDay(List.of("21:00", "09:00")); + MedicationInventory inventory = new MedicationInventory(); + inventory.setPrescription(prescription); + inventory.setQuantityRemaining(new BigDecimal("3.0000")); + inventory.setRefillThreshold(new BigDecimal("5")); + when(prescriptions.findByPatientIdAndStatus(patient.getId(), "ACTIVE")).thenReturn(List.of(prescription)); + when(schedules.findEffectiveForPrescriptions(any(), any())).thenReturn(List.of(schedule)); + when(inventories.findByPrescriptionIdIn(any())).thenReturn(List.of(inventory)); + PatientDetailView view = service.build(patient.getId(), professional, "weekly", LocalDate.of(2026, 7, 12)).orElseThrow(); + PatientDetailView.PrescriptionItem item = view.getPrescriptions().getFirst(); + assertEquals("1 unidades", item.getAmount()); + assertEquals(List.of("09:00", "21:00"), item.getTimes()); + assertEquals("3 em stock", item.getStock()); + assertEquals("bad", item.getStockTone()); + assertNull(item.getPhoto()); + assertEquals(List.of("Medicamento A"), view.getMedicationTags()); + } + + @Test + void prnAndMissingStockHaveHonestFallbacks() { + Medication medication = new Medication(); + medication.setName("Conforme necessário"); + Prescription prescription = new Prescription(); + prescription.setId(UUID.randomUUID()); + prescription.setMedication(medication); + prescription.setPrn(true); + when(prescriptions.findByPatientIdAndStatus(patient.getId(), "ACTIVE")).thenReturn(List.of(prescription)); + PatientDetailView.PrescriptionItem item = service.build(patient.getId(), professional, "weekly", null) + .orElseThrow().getPrescriptions().getFirst(); + assertEquals("Conforme necessário", item.getFrequency()); + assertEquals("Stock não registado", item.getStock()); + assertEquals("—", item.getAmount()); + assertTrue(item.getTimes().isEmpty()); + } + + @Test + void unknownModeFallsBackToWeekly() { + assertEquals("weekly", service.build(patient.getId(), professional, "invalid", LocalDate.of(2026, 7, 1)).orElseThrow().getMode()); + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/PatientDetailTemplateTest.java b/src/test/java/com/voidsoftware/map02/services/PatientDetailTemplateTest.java new file mode 100644 index 0000000..e960259 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/PatientDetailTemplateTest.java @@ -0,0 +1,83 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.Patient; +import com.voidsoftware.map02.entities.Professional; +import com.voidsoftware.map02.models.DoseTakenState; +import com.voidsoftware.map02.models.dto.PatientDoseHistoryDTO; +import com.voidsoftware.map02.repositories.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockServletContext; +import org.thymeleaf.context.WebContext; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; +import org.thymeleaf.web.servlet.JakartaServletWebApplication; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Renders the actual template without starting the app, database, seeders or scheduled jobs. */ +class PatientDetailTemplateTest { + @ParameterizedTest + @CsvSource({"weekly,2026-07-12,7", "monthly,2026-07-12,31", "monthly,2026-06-12,30"}) + void rendersBothViewsAndMondayStartWithoutTemplateErrors(String mode, LocalDate selected, int count) { + PatientRepository patients = mock(PatientRepository.class); + MedicationDoseRepository doses = mock(MedicationDoseRepository.class); + Professional professional = new Professional(); + professional.setId(UUID.randomUUID()); + Patient patient = new Patient(); + patient.setId(UUID.randomUUID()); + patient.setProfessional(professional); + patient.setTimezone("Europe/Lisbon"); + patient.setName("Cristina "); + when(patients.findById(patient.getId())).thenReturn(Optional.of(patient)); + Instant at = selected.atTime(9, 0).toInstant(java.time.ZoneOffset.UTC); + when(doses.findPatientHistory(eq(patient.getId()), any(), any())).thenReturn(List.of( + new PatientDoseHistoryDTO(at, at, DoseTakenState.TAKEN, "Medicamento A"))); + PatientDetailService service = new PatientDetailService(patients, doses, mock(PrescriptionRepository.class), + mock(PrescriptionScheduleRepository.class), mock(MedicationInventoryRepository.class), mock(DataConversionService.class)); + + ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver(); + resolver.setPrefix("templates/"); + resolver.setSuffix(".html"); + resolver.setTemplateMode("HTML"); + resolver.setCharacterEncoding("UTF-8"); + SpringTemplateEngine engine = new SpringTemplateEngine(); + engine.setTemplateResolver(resolver); + MockServletContext servletContext = new MockServletContext(); + MockHttpServletRequest request = new MockHttpServletRequest(servletContext); + request.setRequestURI("/patients/" + patient.getId()); + WebContext context = new WebContext(JakartaServletWebApplication.buildApplication(servletContext) + .buildExchange(request, new MockHttpServletResponse()), Locale.forLanguageTag("pt-PT")); + var detail = service.build(patient.getId(), professional, mode, selected).orElseThrow(); + context.setVariable("patient", detail); + context.setVariable("pageTitle", patient.getName()); + context.setVariable("professionalName", "Dra. Sofia Martins"); + context.setVariable("professionalPhoto", null); + + String html = engine.process("patient-detail", context); + assertTrue(html.contains("dashboardlogo.svg")); + assertTrue(html.contains("Olá")); + assertTrue(html.contains("Dra. Sofia Martins")); + assertTrue(html.contains("topbar-avatar-initial")); + assertEquals(count, detail.getDays().size()); + assertTrue(html.contains(mode.equals("monthly") ? "monthly-chart" : "weekly-chart")); + assertFalse(html.contains(mode.equals("monthly") ? "weekly-chart" : "monthly-chart")); + assertTrue(html.contains("Medicamento A")); + assertTrue(html.contains("Sem prescrições ativas.")); + assertTrue(html.contains("Telefone não registado")); + assertFalse(html.contains("")); + assertFalse(html.contains("th:each")); + assertFalse(html.contains("${patient")); + } +} \ No newline at end of file diff --git a/src/test/java/com/voidsoftware/map02/services/SeedDataServiceTest.java b/src/test/java/com/voidsoftware/map02/services/SeedDataServiceTest.java new file mode 100644 index 0000000..4aa78e6 --- /dev/null +++ b/src/test/java/com/voidsoftware/map02/services/SeedDataServiceTest.java @@ -0,0 +1,153 @@ +package com.voidsoftware.map02.services; + +import com.voidsoftware.map02.entities.*; +import com.voidsoftware.map02.models.FrequencyType; +import com.voidsoftware.map02.models.Role; +import com.voidsoftware.map02.repositories.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.security.access.AccessDeniedException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SeedDataServiceTest { + private final CompanyRepository companies = mock(CompanyRepository.class); + private final ProfessionalRepository professionals = mock(ProfessionalRepository.class); + private final PatientRepository patients = mock(PatientRepository.class); + private final MedicationRepository medications = mock(MedicationRepository.class); + private final PrescriptionRepository prescriptions = mock(PrescriptionRepository.class); + private final PrescriptionScheduleRepository schedules = mock(PrescriptionScheduleRepository.class); + private final PrescriptionsService prescriptionService = new PrescriptionsService(patients, professionals, + prescriptions, medications, schedules); + private final SeedDataService service = new SeedDataService(companies, professionals, patients, medications, prescriptionService); + private AppUser admin; + private Patient patient; + private Medication first; + private Medication second; + + @BeforeEach + void setUp() { + admin = new AppUser(); + admin.setId(UUID.randomUUID()); + admin.setRole(Role.ADMIN); + Professional professional = new Professional(); + professional.setId(UUID.randomUUID()); + patient = new Patient(); + patient.setId(UUID.randomUUID()); + patient.setTimezone("Pacific/Kiritimati"); + patient.setProfessional(professional); + first = medication(); + second = medication(); + when(patients.findById(patient.getId())).thenReturn(Optional.of(patient)); + when(professionals.findById(professional.getId())).thenReturn(Optional.of(professional)); + when(medications.findAll()).thenReturn(List.of(first, second)); + when(medications.findById(first.getId())).thenReturn(Optional.of(first)); + when(medications.findById(second.getId())).thenReturn(Optional.of(second)); + when(prescriptions.save(any())).thenAnswer(invocation -> { + Prescription prescription = invocation.getArgument(0); + prescription.setId(UUID.randomUUID()); + return prescription; + }); + } + + @Test + void constructionDoesNotSeedAnyEntities() { + verifyNoInteractions(companies, professionals, patients, medications, prescriptions, schedules); + } + + @Test + void createsExactCountAndValidInitialSchedulesUsingOnlyExistingMedications() { + LocalDate earliest = LocalDate.now(ZoneId.of(patient.getTimezone())); + List created = service.generatePrescriptions(admin, patient.getId(), 5); + LocalDate latest = LocalDate.now(ZoneId.of(patient.getTimezone())); + assertEquals(5, created.size()); + assertNotEquals(created.get(0).getMedication().getId(), created.get(1).getMedication().getId()); + for (Prescription prescription : created) { + assertSame(patient, prescription.getPatient()); + assertSame(patient.getProfessional(), prescription.getProfessional()); + assertTrue(prescription.getMedication() == first || prescription.getMedication() == second); + assertFalse(prescription.getStartDate().isBefore(earliest)); + assertFalse(prescription.getStartDate().isAfter(latest)); + long duration = java.time.temporal.ChronoUnit.DAYS.between(prescription.getStartDate(), prescription.getEndDate()); + assertTrue(duration >= 7 && duration <= 30); + assertEquals("ACTIVE", prescription.getStatus()); + assertTrue(prescription.getInstructions().contains("DADOS DE TESTE")); + } + ArgumentCaptor captured = ArgumentCaptor.forClass(PrescriptionSchedule.class); + verify(schedules, times(5)).save(captured.capture()); + for (PrescriptionSchedule schedule : captured.getAllValues()) { + assertEquals(1, schedule.getVersion().intValue()); + assertEquals(BigDecimal.ONE, schedule.getDoseAmount()); + assertEquals("unidade de teste", schedule.getDoseUnit()); + int expected = schedule.getFrequencyType() == FrequencyType.ONCE_DAILY ? 1 + : schedule.getFrequencyType() == FrequencyType.TWICE_DAILY ? 2 : 3; + assertEquals(expected, schedule.getTimesOfDay().size()); + assertEquals(schedule.getPrescription().getStartDate().atStartOfDay(ZoneId.of(patient.getTimezone())).toInstant(), + schedule.getEffectiveFrom()); + } + verify(medications, never()).save(any()); + verify(patients, never()).save(any()); + verify(professionals, never()).save(any()); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0, 51, Integer.MAX_VALUE}) + void rejectsInvalidQuantityBeforeDatabaseAccess(int count) { + assertThrows(IllegalArgumentException.class, () -> service.generatePrescriptions(admin, patient.getId(), count)); + verifyNoInteractions(patients, medications, prescriptions, schedules); + } + + @Test + void supportsMaximumBatchSizeWithASmallCatalog() { + assertEquals(50, service.generatePrescriptions(admin, patient.getId(), 50).size()); + verify(schedules, times(50)).save(any()); + } + + @Test + void rejectsMissingPatientProfessionalOrMedicationWithoutWrites() { + when(patients.findById(patient.getId())).thenReturn(Optional.empty()); + assertThrows(IllegalArgumentException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + when(patients.findById(patient.getId())).thenReturn(Optional.of(patient)); + Professional professional = patient.getProfessional(); + patient.setProfessional(null); + assertThrows(IllegalArgumentException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + patient.setProfessional(professional); + when(medications.findAll()).thenReturn(List.of()); + assertThrows(IllegalArgumentException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + verifyNoInteractions(prescriptions, schedules); + } + + @Test + void rejectsInvalidTimezoneWithoutWrites() { + patient.setTimezone("invalid/timezone"); + assertThrows(IllegalArgumentException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + verifyNoInteractions(prescriptions, schedules); + } + + @Test + void rejectsNonAdminOrDisabledActors() { + admin.setRole(Role.PROFESSIONAL); + assertThrows(AccessDeniedException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + admin.setRole(Role.ADMIN); + admin.setEnabled(false); + assertThrows(AccessDeniedException.class, () -> service.generatePrescriptions(admin, patient.getId(), 1)); + verifyNoInteractions(patients, medications, prescriptions, schedules); + } + + private Medication medication() { + Medication medication = new Medication(); + medication.setId(UUID.randomUUID()); + return medication; + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..c7fab6c --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,11 @@ +spring.datasource.url=jdbc:h2:mem:map02-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +spring.main.banner-mode=off