frontend code adding
This commit is contained in:
parent
a3f4586ced
commit
d0de2caa9d
16
src/main/java/com/voidsoftware/map02/Map02Application.java
Normal file
16
src/main/java/com/voidsoftware/map02/Map02Application.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
258
src/main/java/com/voidsoftware/map02/client/MedMapClient.java
Normal file
258
src/main/java/com/voidsoftware/map02/client/MedMapClient.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
10
src/main/java/com/voidsoftware/map02/config/CurrentUser.java
Normal file
10
src/main/java/com/voidsoftware/map02/config/CurrentUser.java
Normal 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 {
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
126
src/main/java/com/voidsoftware/map02/config/SecurityConfig.java
Normal file
126
src/main/java/com/voidsoftware/map02/config/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
16
src/main/java/com/voidsoftware/map02/config/WebConfig.java
Normal file
16
src/main/java/com/voidsoftware/map02/config/WebConfig.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
101
src/main/java/com/voidsoftware/map02/entities/AppUser.java
Normal file
101
src/main/java/com/voidsoftware/map02/entities/AppUser.java
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/main/java/com/voidsoftware/map02/entities/Company.java
Normal file
45
src/main/java/com/voidsoftware/map02/entities/Company.java
Normal 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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.voidsoftware.map02.entities;
|
||||
|
||||
public class CompanyProfessionals {
|
||||
}
|
||||
55
src/main/java/com/voidsoftware/map02/entities/DoseEvent.java
Normal file
55
src/main/java/com/voidsoftware/map02/entities/DoseEvent.java
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
136
src/main/java/com/voidsoftware/map02/entities/Patient.java
Normal file
136
src/main/java/com/voidsoftware/map02/entities/Patient.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
package com.voidsoftware.map02.models;
|
||||
|
||||
public enum DayPeriodTypes {
|
||||
|
||||
MORNING,
|
||||
LUNCH,
|
||||
AFTERNOON,
|
||||
DINNER,
|
||||
BEDTIME
|
||||
}
|
||||
16
src/main/java/com/voidsoftware/map02/models/DosageForm.java
Normal file
16
src/main/java/com/voidsoftware/map02/models/DosageForm.java
Normal 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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
9
src/main/java/com/voidsoftware/map02/models/Gender.java
Normal file
9
src/main/java/com/voidsoftware/map02/models/Gender.java
Normal file
@ -0,0 +1,9 @@
|
||||
package com.voidsoftware.map02.models;
|
||||
|
||||
public enum Gender {
|
||||
MALE,
|
||||
FEMALE,
|
||||
NON_BINARY,
|
||||
OTHER,
|
||||
PREFER_NOT_TO_SAY
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.voidsoftware.map02.models;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface MediaEntity {
|
||||
|
||||
|
||||
public UUID getResourceId();
|
||||
}
|
||||
7
src/main/java/com/voidsoftware/map02/models/Role.java
Normal file
7
src/main/java/com/voidsoftware/map02/models/Role.java
Normal file
@ -0,0 +1,7 @@
|
||||
package com.voidsoftware.map02.models;
|
||||
|
||||
public enum Role {
|
||||
PATIENT,
|
||||
PROFESSIONAL,
|
||||
ADMIN
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.voidsoftware.map02.models.dto;
|
||||
|
||||
import com.voidsoftware.map02.models.DoseTakenState;
|
||||
|
||||
public record DoseStateCountDTO(
|
||||
DoseTakenState status,
|
||||
long count
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
|
||||
) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.voidsoftware.map02.models.dto;
|
||||
|
||||
public record PrescriptionDTO() {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user