frontend code adding

This commit is contained in:
Ricardo Araújo 2026-09-15 14:14:54 +01:00
parent a3f4586ced
commit d0de2caa9d
154 changed files with 9827 additions and 0 deletions

View File

@ -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);
}
}

View File

@ -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<PrescriptionWithSchedulesResponse> listPrescriptions(ListPrescriptionsRequest req)
throws IOException, InterruptedException {
return postList("/api/prescriptions/list", req, new TypeReference<>() {
}, 200);
}
// ── Doses ─────────────────────────────────────────────────────────────────
public List<MedicationDose> generateDoses(GenerateDosesRequest req)
throws IOException, InterruptedException {
return postList("/api/doses/generate", req, new TypeReference<>() {
}, 200);
}
public List<MedicationDose> generateDosesAll(GenerateDosesRequest req)
throws IOException, InterruptedException {
return postList("/api/doses/generate/all", req, new TypeReference<>() {
}, 200);
}
// ── Core HTTP helpers ─────────────────────────────────────────────────────
private <T> T post(String path, Object body, Class<T> responseType, int expectedStatus)
throws IOException, InterruptedException {
return send("POST", path, body, responseType, expectedStatus);
}
private <T> List<T> postList(String path, Object body, TypeReference<List<T>> responseType, int expectedStatus)
throws IOException, InterruptedException {
return sendList("POST", path, body, responseType, expectedStatus);
}
private <T> T put(String path, Object body, Class<T> responseType, int expectedStatus)
throws IOException, InterruptedException {
return send("PUT", path, body, responseType, expectedStatus);
}
private <T> T get(String path, Class<T> responseType, int expectedStatus)
throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> 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> List<T> getList(String path, TypeReference<List<T>> responseType, int expectedStatus)
throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> 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> T send(String method, String path, Object body, Class<T> 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<String> 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 <T> List<T> sendList(String method, String path, Object body, TypeReference<List<T>> 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<String> 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;
}
}
}

View File

@ -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);
}
}

View File

@ -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 {
}

View File

@ -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();
}
}

View File

@ -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")));
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserArgumentResolver());
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}
}

View File

@ -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<CompanyDTO> 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<ListOfCompaniesResponse> listAll() {
return ResponseEntity.ok(companyService.getAllCompanies());
}
}

View File

@ -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> professional = permissionsValidatorService.validateProfessional(sessionUser);
if (professional.isEmpty()) {
return "redirect:/login";
}
model.addAttribute("pageTitle", "Dashboard");
model.addAttribute("dashboard", dashboardService.buildDashboard(professional.get()));
return "dashboard";
}
}

View File

@ -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<List<MedicationDose>> generateDoses(@Valid @RequestBody GenerateDosesRequest request) {
List<MedicationDose> 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<List<MedicationDose>> generateAllDoses(@Valid @RequestBody GenerateDosesRequest request) {
List<MedicationDose> doses = doseGenerationService.generateDosesForAllPatients(
request.getFromDate(), request.getToDate());
return ResponseEntity.ok(doses);
}
@Operation(summary = "Mark dose as taken")
@PostMapping("/{doseId}/take")
public ResponseEntity<MedicationDose> 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<MedicationDose> 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<MedicationDose> 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));
}
}

View File

@ -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<MedicationDTO> 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<ListOfMedicationsResponse> listAll(@CurrentUser AppUser sessionUser) {
return ResponseEntity.ok(medicationService.getAllMedications(sessionUser));
}
}

View File

@ -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> 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");
}
}

View File

@ -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<PatientDTO> 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<Void> 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<PatientDTO> 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<ListOfPatientsResponse> 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<ListOfPatientPeriods> getPatientMedicationDayPeriods(@CurrentUser AppUser sessionUser) {
return ResponseEntity.ok(this.patientsService.getPatientMedicationDayPeriods(sessionUser));
}
}

View File

@ -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<Prescription> 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<List<PrescriptionWithSchedulesResponse>> listPrescriptions(
@Valid @RequestBody ListPrescriptionsRequest request) {
return ResponseEntity.ok(prescriptionsService.listPrescriptions(request));
}
}

View File

@ -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<ProfessionalDTO> 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<ListOfProfessionalsResponse> listByCompany(@Valid @RequestBody ListProfessionalsRequest request) {
return ResponseEntity.ok(professionalsService.listByCompany(request));
}
}

View File

@ -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<LoginResponse> 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<LoginResponse> 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));
}
}

View File

@ -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<? extends GrantedAuthority> 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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,4 @@
package com.voidsoftware.map02.entities;
public class CompanyProfessionals {
}

View File

@ -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<String, Object> metadata;
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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<String> 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();
}
}

View File

@ -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;
}

View File

@ -0,0 +1,10 @@
package com.voidsoftware.map02.models;
public enum DayPeriodTypes {
MORNING,
LUNCH,
AFTERNOON,
DINNER,
BEDTIME
}

View File

@ -0,0 +1,16 @@
package com.voidsoftware.map02.models;
public enum DosageForm {
TABLET,
CAPSULE,
LIQUID,
INJECTION,
PATCH,
INHALER,
DROPS,
CREAM,
GEL,
SUPPOSITORY,
POWDER,
SPRAY
}

View File

@ -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;
}
}

View File

@ -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
}

View File

@ -0,0 +1,9 @@
package com.voidsoftware.map02.models;
public enum Gender {
MALE,
FEMALE,
NON_BINARY,
OTHER,
PREFER_NOT_TO_SAY
}

View File

@ -0,0 +1,9 @@
package com.voidsoftware.map02.models;
import java.util.UUID;
public interface MediaEntity {
public UUID getResourceId();
}

View File

@ -0,0 +1,7 @@
package com.voidsoftware.map02.models;
public enum Role {
PATIENT,
PROFESSIONAL,
ADMIN
}

View File

@ -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;
}

View File

@ -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) {
}

View File

@ -0,0 +1,9 @@
package com.voidsoftware.map02.models.dto;
import com.voidsoftware.map02.models.DoseTakenState;
public record DoseStateCountDTO(
DoseTakenState status,
long count
) {
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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) {
}

View File

@ -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
) {
}

View File

@ -0,0 +1,4 @@
package com.voidsoftware.map02.models.dto;
public record PrescriptionDTO() {
}

View File

@ -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
) {
}

View File

@ -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) {
}

View File

@ -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<String> 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;
}

View File

@ -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) {
}

View File

@ -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
) {
}

View File

@ -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;
}

View File

@ -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<String> timesOfDay;
@NotNull
@DecimalMin(value = "0.0001", message = "Dose amount must be positive")
private BigDecimal doseAmount;
@NotBlank
private String doseUnit;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<CompanyDTO> companies) {
}

View File

@ -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<MedicationDTO> medications) {
}

View File

@ -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<PatientPeriodDTO> periods) {
}

View File

@ -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<PatientDTO> patients) {
}

View File

@ -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<ProfessionalDTO> professionals) {
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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<PrescriptionSchedule> schedules;
public PrescriptionWithSchedulesResponse(Prescription prescription,
List<PrescriptionSchedule> schedules) {
this.prescription = prescription;
this.schedules = schedules;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<AdminUserView> users;
Page<CompanyItem> companies;
Page<ProfessionalItem> professionals;
Page<PatientItem> 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<ProfessionalItem> 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<PatientItem> patients;
}
@Value
public static class PatientItem {
UUID id;
String name;
String email;
String phone;
boolean active;
String professionalName;
String companyName;
}
}

View File

@ -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";
};
}
}

View File

@ -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;
}
}

View File

@ -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<AlertItemView> alerts;
private final List<PatientCardView> patients;
public DashboardViewModel(String professionalName, String professionalPhoto, int adherencePercent,
int nonAdherencePercent, List<AlertItemView> alerts, List<PatientCardView> patients) {
this.professionalName = professionalName;
this.professionalPhoto = professionalPhoto;
this.adherencePercent = adherencePercent;
this.nonAdherencePercent = nonAdherencePercent;
this.alerts = alerts;
this.patients = patients;
}
}

View File

@ -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<String> medicationTags;
private final long todayDoseCount;
private final String nextDoseTime;
private final String ringStyle;
public PatientCardView(UUID patientId, String name, Integer age, String photo, List<String> 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;
}
}

View File

@ -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<String> medicationTags;
String mode;
LocalDate selectedDate;
String periodLabel;
LocalDate previousDate;
LocalDate nextDate;
List<LocalDate> periodLinks;
int leadingDays;
int adherencePercent;
long takenCount;
long totalCount;
List<Day> days;
List<Dose> history;
List<PrescriptionItem> 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<String> times;
String frequency;
String stock;
String stockTone;
String instructions;
}
}

View File

@ -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<AppUser, UUID> {
Optional<AppUser> 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<AppUser> lockByRole(@Param("role") Role role);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM AppUser u WHERE u.id = :id")
Optional<AppUser> 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<AdminUserView> findAdminUsers(@Param("query") String query, Pageable pageable);
}

View File

@ -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<Company, UUID> {
Page<Company> findByNameContainingIgnoreCase(String name, Pageable pageable);
}

View File

@ -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<DoseEvent, UUID> {
}

View File

@ -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<DoseGenerationRun, UUID> {
boolean existsByRunDate(LocalDate runDate);
Optional<DoseGenerationRun> findByRunDate(LocalDate runDate);
}

View File

@ -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<MediaResource, UUID> {
/** Lookup by content hash to avoid storing duplicate files. */
Optional<MediaResource> findByHash(String hash);
}

View File

@ -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<MedicationAlert, UUID> {
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<MedicationAlert> findRecentByProfessionalId(@Param("professionalId") UUID professionalId, Pageable pageable);
}

View File

@ -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<MedicationDose, UUID> {
/** 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<com.voidsoftware.map02.models.dto.PatientDoseHistoryDTO> 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<MedicationDose> findByPrescriptionIdAndScheduledAtBetweenOrderByScheduledAtAsc(
UUID prescriptionId, Instant from, Instant to);
/**
* Every dose ever generated for a prescription, in chronological order.
*/
List<MedicationDose> findByPrescriptionIdOrderByScheduledAtAsc(UUID prescriptionId);
/**
* Still-pending doses whose scheduled time has already passed; candidates for a MISSED_DOSE sweep.
*/
List<MedicationDose> findByStatusAndScheduledAtBefore(DoseTakenState status, Instant cutoff);
/**
* Still-pending doses whose server-local scheduled time has already passed; candidates for a MISSED_DOSE sweep.
*/
List<MedicationDose> 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<MedicationDose> 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<MedicationDose> findByProfessionalIdAndStatusIn(@Param("professionalId") UUID professionalId,
@Param("statuses") Collection<DoseTakenState> 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<MedicationDose> 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<MedicationDose> 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<DoseStateCountDTO> 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<DoseStateCountDTO> countByPatientIdBetweenGroupByStatus(@Param("patientId") UUID patientId,
@Param("cutoff") java.time.LocalDateTime cutoff);
}

View File

@ -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<MedicationInventory, UUID> {
Optional<MedicationInventory> findByPrescriptionId(UUID prescriptionId);
List<MedicationInventory> findByPrescriptionIdIn(Collection<UUID> prescriptionIds);
}

View File

@ -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<Medication, UUID> {
}

View File

@ -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<NotificationPreference, UUID> {
}

View File

@ -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<PatientPeriods, UUID> {
List<PatientPeriods> findByPatientId(UUID patientId);
}

View File

@ -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<Patient, UUID> {
List<Patient> findByProfessionalId(UUID professionalId);
@EntityGraph(attributePaths = {"professional", "professional.company"})
List<Patient> findByProfessionalIdInOrderByNameAsc(Collection<UUID> professionalIds);
@EntityGraph(attributePaths = {"professional", "professional.company"})
Page<Patient> findByNameContainingIgnoreCase(String name, Pageable pageable);
Optional<Patient> findByEmail(String email);
Optional<Patient> 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);
}

View File

@ -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<Prescription, UUID> {
List<Prescription> findByPatientId(UUID patientId);
@EntityGraph(attributePaths = "medication")
List<Prescription> findByPatientIdAndStatus(UUID patientId, String status);
/** All prescriptions with the given status, across every patient; used by the daily dose generation sweep. */
List<Prescription> findByStatus(String status);
}

View File

@ -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<PrescriptionSchedule, UUID> {
/** 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<PrescriptionSchedule> findEffectiveForPrescriptions(@Param("ids") Collection<UUID> 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<PrescriptionSchedule> 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<PrescriptionSchedule> 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<PrescriptionSchedule> findActiveListByPrescriptionId(@Param("prescriptionId") UUID prescriptionId);
}

View File

@ -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<Professional, UUID> {
Optional<Professional> findByEmail(String email);
List<Professional> findByCompanyId(UUID companyId);
@EntityGraph(attributePaths = "company")
List<Professional> findByCompanyIdInOrderByNameAsc(Collection<UUID> companyIds);
@EntityGraph(attributePaths = "company")
Page<Professional> 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);
}

View File

@ -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<AdminUserView> userPage = Page.empty();
Page<CompanyItem> companyPage = Page.empty();
Page<ProfessionalItem> professionalPage = Page.empty();
Page<PatientItem> 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<Professional> staff = result.isEmpty() ? List.of() : professionals.findByCompanyIdInOrderByNameAsc(
result.getContent().stream().map(c -> c.getId()).toList());
Map<UUID, List<PatientItem>> assigned = patientsByProfessional(staff);
Map<UUID, List<ProfessionalItem>> 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<UUID, List<PatientItem>> 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 <T> Page<T> boundedPage(Pageable pageable, Function<Pageable, Page<T>> query) {
Page<T> 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<UUID, List<PatientItem>> patientsByProfessional(List<Professional> 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<UUID, List<PatientItem>> 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());
}
}

View File

@ -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<AppUser> 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<AppUser> 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<AppUser> 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<AppUser> 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.");
}
}
}

View File

@ -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));
}
}

Some files were not shown because too many files have changed in this diff Show More