feat(sprint-3): Phase 5 — member portal (session-based auth)
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.PortalPrincipal;
|
||||||
|
import de.cannamanage.service.PortalService;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member self-service portal — read-only JSON endpoints.
|
||||||
|
* All data is scoped to the authenticated member via session principal.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/portal")
|
||||||
|
public class PortalController {
|
||||||
|
|
||||||
|
private final PortalService portalService;
|
||||||
|
|
||||||
|
public PortalController(PortalService portalService) {
|
||||||
|
this.portalService = portalService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + recent distributions (last 5).
|
||||||
|
*/
|
||||||
|
@GetMapping("/dashboard")
|
||||||
|
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current month quota status (daily + monthly, used/remaining).
|
||||||
|
*/
|
||||||
|
@GetMapping("/quota")
|
||||||
|
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
|
||||||
|
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
|
||||||
|
return ResponseEntity.ok(quota);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Own distribution history, paginated.
|
||||||
|
*/
|
||||||
|
@GetMapping("/distributions")
|
||||||
|
public ResponseEntity<PortalDistributionHistory> distributions(
|
||||||
|
@AuthenticationPrincipal PortalPrincipal principal,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(
|
||||||
|
principal.getTenantId(), principal.getMemberId(), pageable);
|
||||||
|
return ResponseEntity.ok(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom UserDetails principal for member portal sessions.
|
||||||
|
* Carries tenantId and memberId so portal controllers can enforce data scoping.
|
||||||
|
*/
|
||||||
|
public class PortalPrincipal extends User {
|
||||||
|
|
||||||
|
private final UUID tenantId;
|
||||||
|
private final UUID memberId;
|
||||||
|
|
||||||
|
public PortalPrincipal(String username, String password,
|
||||||
|
Collection<? extends GrantedAuthority> authorities,
|
||||||
|
UUID tenantId, UUID memberId) {
|
||||||
|
super(username, password, authorities);
|
||||||
|
this.tenantId = tenantId;
|
||||||
|
this.memberId = memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTenantId() {
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getMemberId() {
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package de.cannamanage.api.security;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserDetailsService for portal session-based auth.
|
||||||
|
* Only loads MEMBER-role users who are active. Members log in by email.
|
||||||
|
*/
|
||||||
|
@Service("portalUserDetailsService")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PortalUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
|
||||||
|
|
||||||
|
// Only MEMBER role users may use the portal
|
||||||
|
if (user.getRole() != UserRole.ROLE_MEMBER) {
|
||||||
|
throw new UsernameNotFoundException("User is not a member");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be active
|
||||||
|
if (!user.isActive()) {
|
||||||
|
throw new UsernameNotFoundException("User account is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a linked memberId
|
||||||
|
if (user.getMemberId() == null) {
|
||||||
|
throw new UsernameNotFoundException("User has no linked member profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
|
||||||
|
|
||||||
|
return new PortalPrincipal(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPasswordHash(),
|
||||||
|
authorities,
|
||||||
|
user.getTenantId(),
|
||||||
|
user.getMemberId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security configuration — Sprint 3: API + Staff portal with JWT.
|
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
|
||||||
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
|
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -24,6 +27,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthFilter jwtAuthFilter;
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
private final PortalUserDetailsService portalUserDetailsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API security — stateless JWT authentication.
|
* API security — stateless JWT authentication.
|
||||||
@@ -53,10 +57,52 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public endpoints — Swagger UI, actuator health.
|
* Member portal — session-based authentication with CSRF protection.
|
||||||
|
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
@Order(2)
|
@Order(2)
|
||||||
|
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/portal/**")
|
||||||
|
.csrf(csrf -> csrf
|
||||||
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||||
|
.maximumSessions(1))
|
||||||
|
.userDetailsService(portalUserDetailsService)
|
||||||
|
.formLogin(form -> form
|
||||||
|
.loginProcessingUrl("/portal/login")
|
||||||
|
.successHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"ok\"}");
|
||||||
|
})
|
||||||
|
.failureHandler((request, response, exception) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
|
||||||
|
})
|
||||||
|
.permitAll())
|
||||||
|
.logout(logout -> logout
|
||||||
|
.logoutUrl("/portal/logout")
|
||||||
|
.logoutSuccessHandler((request, response, authentication) -> {
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.getWriter().write("{\"status\":\"logged_out\"}");
|
||||||
|
}))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
|
||||||
|
.requestMatchers("/portal/**").hasRole("MEMBER"));
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public endpoints — Swagger UI, actuator health.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Order(3)
|
||||||
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")
|
||||||
|
|||||||
@@ -30,3 +30,9 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
|
|||||||
|
|
||||||
# App base URL (for invite links)
|
# App base URL (for invite links)
|
||||||
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# Session configuration (member portal)
|
||||||
|
server.servlet.session.timeout=30m
|
||||||
|
server.servlet.session.cookie.same-site=strict
|
||||||
|
server.servlet.session.cookie.http-only=true
|
||||||
|
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.api.security.JwtAuthFilter;
|
||||||
|
import de.cannamanage.api.security.JwtService;
|
||||||
|
import de.cannamanage.api.security.PortalPrincipal;
|
||||||
|
import de.cannamanage.api.security.PortalUserDetailsService;
|
||||||
|
import de.cannamanage.service.PortalService;
|
||||||
|
import de.cannamanage.service.TokenRevocationService;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.mock.bean.MockBean;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(PortalController.class)
|
||||||
|
class PortalControllerTest {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockBean private PortalService portalService;
|
||||||
|
@MockBean private UserRepository userRepository;
|
||||||
|
@MockBean private JwtService jwtService;
|
||||||
|
@MockBean private JwtAuthFilter jwtAuthFilter;
|
||||||
|
@MockBean private TokenRevocationService tokenRevocationService;
|
||||||
|
@MockBean private PortalUserDetailsService portalUserDetailsService;
|
||||||
|
|
||||||
|
private final UUID tenantId = UUID.randomUUID();
|
||||||
|
private final UUID memberId = UUID.randomUUID();
|
||||||
|
|
||||||
|
private PortalPrincipal createPrincipal() {
|
||||||
|
return new PortalPrincipal(
|
||||||
|
"member@test.de", "password",
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_MEMBER")),
|
||||||
|
tenantId, memberId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dashboard_returnsCorrectQuotaData() throws Exception {
|
||||||
|
PortalPrincipal principal = createPrincipal();
|
||||||
|
PortalDashboard dashboard = new PortalDashboard(
|
||||||
|
"Max Mustermann", "CM-001",
|
||||||
|
new BigDecimal("12.5"), new BigDecimal("37.5"),
|
||||||
|
new BigDecimal("5.0"), new BigDecimal("20.0"),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
when(portalService.getDashboard(tenantId, memberId)).thenReturn(dashboard);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/portal/dashboard").with(user(principal)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.memberName").value("Max Mustermann"))
|
||||||
|
.andExpect(jsonPath("$.membershipNumber").value("CM-001"))
|
||||||
|
.andExpect(jsonPath("$.monthlyQuotaUsed").value(12.5))
|
||||||
|
.andExpect(jsonPath("$.dailyQuotaRemaining").value(20.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void portal_returns403_forUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/portal/dashboard"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void profile_returnsOwnData() throws Exception {
|
||||||
|
PortalPrincipal principal = createPrincipal();
|
||||||
|
PortalProfile profile = new PortalProfile(
|
||||||
|
"Max", "Mustermann", "CM-001",
|
||||||
|
LocalDate.of(2025, 1, 15), MemberStatus.ACTIVE, "max@example.com"
|
||||||
|
);
|
||||||
|
when(portalService.getProfile(tenantId, memberId)).thenReturn(profile);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/portal/me").with(user(principal)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.firstName").value("Max"))
|
||||||
|
.andExpect(jsonPath("$.membershipNumber").value("CM-001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quota_returnsCurrentMonthStatus() throws Exception {
|
||||||
|
PortalPrincipal principal = createPrincipal();
|
||||||
|
PortalQuota quota = new PortalQuota(
|
||||||
|
2026, 6,
|
||||||
|
new BigDecimal("5.0"), new BigDecimal("25.0"),
|
||||||
|
new BigDecimal("20.0"), new BigDecimal("50.0"),
|
||||||
|
false, new BigDecimal("30.0")
|
||||||
|
);
|
||||||
|
when(portalService.getQuota(tenantId, memberId)).thenReturn(quota);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/portal/quota").with(user(principal)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.year").value(2026))
|
||||||
|
.andExpect(jsonPath("$.month").value(6))
|
||||||
|
.andExpect(jsonPath("$.dailyUsed").value(5.0))
|
||||||
|
.andExpect(jsonPath("$.monthlyLimit").value(50.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service layer for the member self-service portal.
|
||||||
|
* All methods enforce member-scoped data access — only the specified memberId's data is returned.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class PortalService {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final DistributionRepository distributionRepository;
|
||||||
|
private final ComplianceService complianceService;
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
private final StrainRepository strainRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public PortalService(MemberRepository memberRepository,
|
||||||
|
DistributionRepository distributionRepository,
|
||||||
|
ComplianceService complianceService,
|
||||||
|
BatchRepository batchRepository,
|
||||||
|
StrainRepository strainRepository,
|
||||||
|
UserRepository userRepository) {
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.distributionRepository = distributionRepository;
|
||||||
|
this.complianceService = complianceService;
|
||||||
|
this.batchRepository = batchRepository;
|
||||||
|
this.strainRepository = strainRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard: quota summary + last 5 distributions.
|
||||||
|
*/
|
||||||
|
public PortalDashboard getDashboard(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
|
||||||
|
|
||||||
|
// Daily usage
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||||
|
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
|
||||||
|
|
||||||
|
// Recent 5 distributions
|
||||||
|
List<Distribution> recent = distributionRepository
|
||||||
|
.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId);
|
||||||
|
|
||||||
|
List<PortalDashboard.RecentDistribution> recentDtos = recent.stream()
|
||||||
|
.map(d -> new PortalDashboard.RecentDistribution(
|
||||||
|
d.getDistributedAt(),
|
||||||
|
resolveStrainName(d.getBatchId()),
|
||||||
|
d.getQuantityGrams(),
|
||||||
|
resolveStaffName(d.getRecordedBy())
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new PortalDashboard(
|
||||||
|
member.getFirstName() + " " + member.getLastName(),
|
||||||
|
member.getMembershipNumber(),
|
||||||
|
quota.totalUsed(),
|
||||||
|
quota.remaining(),
|
||||||
|
dailyUsed,
|
||||||
|
dailyLimit.subtract(dailyUsed),
|
||||||
|
recentDtos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile.
|
||||||
|
*/
|
||||||
|
public PortalProfile getProfile(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
return new PortalProfile(
|
||||||
|
member.getFirstName(),
|
||||||
|
member.getLastName(),
|
||||||
|
member.getMembershipNumber(),
|
||||||
|
member.getMembershipDate(),
|
||||||
|
member.getStatus(),
|
||||||
|
member.getEmail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed quota status for current month.
|
||||||
|
*/
|
||||||
|
public PortalQuota getQuota(UUID tenantId, UUID memberId) {
|
||||||
|
Member member = loadMember(memberId);
|
||||||
|
QuotaStatus quota = complianceService.getQuotaStatus(memberId);
|
||||||
|
|
||||||
|
// Daily usage
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
BigDecimal dailyUsed = distributionRepository.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||||
|
|
||||||
|
return new PortalQuota(
|
||||||
|
quota.year(),
|
||||||
|
quota.month(),
|
||||||
|
dailyUsed,
|
||||||
|
ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS,
|
||||||
|
quota.totalUsed(),
|
||||||
|
quota.totalAllowed(),
|
||||||
|
member.isUnder21(),
|
||||||
|
ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for the member.
|
||||||
|
*/
|
||||||
|
public PortalDistributionHistory getDistributionHistory(UUID tenantId, UUID memberId, Pageable pageable) {
|
||||||
|
Page<Distribution> page = distributionRepository
|
||||||
|
.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable);
|
||||||
|
|
||||||
|
List<PortalDistributionHistory.DistributionEntry> entries = page.getContent().stream()
|
||||||
|
.map(d -> new PortalDistributionHistory.DistributionEntry(
|
||||||
|
d.getDistributedAt(),
|
||||||
|
resolveStrainName(d.getBatchId()),
|
||||||
|
d.getQuantityGrams(),
|
||||||
|
resolveStaffName(d.getRecordedBy())
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new PortalDistributionHistory(entries, page.getNumber(), page.getTotalPages(), page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Member loadMember(UUID memberId) {
|
||||||
|
return memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveStrainName(UUID batchId) {
|
||||||
|
return batchRepository.findById(batchId)
|
||||||
|
.flatMap(batch -> strainRepository.findById(batch.getStrainId()))
|
||||||
|
.map(Strain::getName)
|
||||||
|
.orElse("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveStaffName(UUID userId) {
|
||||||
|
return userRepository.findById(userId)
|
||||||
|
.map(User::getEmail)
|
||||||
|
.orElse("Unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard overview for portal members — quota summary + recent distributions.
|
||||||
|
*/
|
||||||
|
public record PortalDashboard(
|
||||||
|
String memberName,
|
||||||
|
String membershipNumber,
|
||||||
|
BigDecimal monthlyQuotaUsed,
|
||||||
|
BigDecimal monthlyQuotaRemaining,
|
||||||
|
BigDecimal dailyQuotaUsed,
|
||||||
|
BigDecimal dailyQuotaRemaining,
|
||||||
|
List<RecentDistribution> recentDistributions
|
||||||
|
) {
|
||||||
|
public record RecentDistribution(
|
||||||
|
Instant date,
|
||||||
|
String strainName,
|
||||||
|
BigDecimal grams,
|
||||||
|
String staffName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for a member.
|
||||||
|
*/
|
||||||
|
public record PortalDistributionHistory(
|
||||||
|
List<DistributionEntry> distributions,
|
||||||
|
int page,
|
||||||
|
int totalPages,
|
||||||
|
long totalElements
|
||||||
|
) {
|
||||||
|
public record DistributionEntry(
|
||||||
|
Instant date,
|
||||||
|
String strainName,
|
||||||
|
BigDecimal grams,
|
||||||
|
String staffName
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member's own profile information for the portal.
|
||||||
|
*/
|
||||||
|
public record PortalProfile(
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String membershipNumber,
|
||||||
|
LocalDate membershipDate,
|
||||||
|
MemberStatus status,
|
||||||
|
String email
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package de.cannamanage.service.dto.portal;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed quota status for the current month.
|
||||||
|
*/
|
||||||
|
public record PortalQuota(
|
||||||
|
int year,
|
||||||
|
int month,
|
||||||
|
BigDecimal dailyUsed,
|
||||||
|
BigDecimal dailyLimit,
|
||||||
|
BigDecimal monthlyUsed,
|
||||||
|
BigDecimal monthlyLimit,
|
||||||
|
boolean isUnder21,
|
||||||
|
BigDecimal under21MonthlyLimit
|
||||||
|
) {}
|
||||||
+12
@@ -1,6 +1,8 @@
|
|||||||
package de.cannamanage.service.repository;
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
import de.cannamanage.domain.entity.Distribution;
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -52,4 +54,14 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
|||||||
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
|
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
|
||||||
"ORDER BY d.distributedAt DESC LIMIT 1")
|
"ORDER BY d.distributedAt DESC LIMIT 1")
|
||||||
Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId);
|
Distribution findLatestByTenantIdAndMemberId(@Param("tenantId") UUID tenantId, @Param("memberId") UUID memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 5 most recent distributions for a specific member (portal dashboard).
|
||||||
|
*/
|
||||||
|
List<Distribution> findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated distribution history for a member, newest first (portal history).
|
||||||
|
*/
|
||||||
|
Page<Distribution> findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.entity.User;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDashboard;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalProfile;
|
||||||
|
import de.cannamanage.service.dto.portal.PortalQuota;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import de.cannamanage.service.repository.UserRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PortalServiceTest {
|
||||||
|
|
||||||
|
@Mock private MemberRepository memberRepository;
|
||||||
|
@Mock private DistributionRepository distributionRepository;
|
||||||
|
@Mock private ComplianceService complianceService;
|
||||||
|
@Mock private BatchRepository batchRepository;
|
||||||
|
@Mock private StrainRepository strainRepository;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks private PortalService portalService;
|
||||||
|
|
||||||
|
private final UUID tenantId = UUID.randomUUID();
|
||||||
|
private final UUID memberId = UUID.randomUUID();
|
||||||
|
private Member testMember;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testMember = new Member();
|
||||||
|
testMember.setFirstName("Max");
|
||||||
|
testMember.setLastName("Mustermann");
|
||||||
|
testMember.setMembershipNumber("CM-001");
|
||||||
|
testMember.setMembershipDate(LocalDate.of(2025, 1, 15));
|
||||||
|
testMember.setStatus(MemberStatus.ACTIVE);
|
||||||
|
testMember.setEmail("max@example.com");
|
||||||
|
testMember.setUnder21(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDashboard_returnsCorrectQuotaData() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
when(complianceService.getQuotaStatus(memberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("12.5"),
|
||||||
|
new BigDecimal("37.5"), false, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
|
||||||
|
.thenReturn(new BigDecimal("5.0"));
|
||||||
|
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(dashboard.memberName()).isEqualTo("Max Mustermann");
|
||||||
|
assertThat(dashboard.membershipNumber()).isEqualTo("CM-001");
|
||||||
|
assertThat(dashboard.monthlyQuotaUsed()).isEqualByComparingTo("12.5");
|
||||||
|
assertThat(dashboard.monthlyQuotaRemaining()).isEqualByComparingTo("37.5");
|
||||||
|
assertThat(dashboard.dailyQuotaUsed()).isEqualByComparingTo("5.0");
|
||||||
|
assertThat(dashboard.dailyQuotaRemaining()).isEqualByComparingTo("20.0");
|
||||||
|
assertThat(dashboard.recentDistributions()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProfile_returnsOwnMemberData() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
|
||||||
|
PortalProfile profile = portalService.getProfile(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(profile.firstName()).isEqualTo("Max");
|
||||||
|
assertThat(profile.lastName()).isEqualTo("Mustermann");
|
||||||
|
assertThat(profile.membershipNumber()).isEqualTo("CM-001");
|
||||||
|
assertThat(profile.membershipDate()).isEqualTo(LocalDate.of(2025, 1, 15));
|
||||||
|
assertThat(profile.status()).isEqualTo(MemberStatus.ACTIVE);
|
||||||
|
assertThat(profile.email()).isEqualTo("max@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getQuota_returnsDetailedQuotaStatus() {
|
||||||
|
when(memberRepository.findById(memberId)).thenReturn(Optional.of(testMember));
|
||||||
|
when(complianceService.getQuotaStatus(memberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("50.0"), new BigDecimal("20.0"),
|
||||||
|
new BigDecimal("30.0"), false, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(memberId), any(), any()))
|
||||||
|
.thenReturn(new BigDecimal("10.0"));
|
||||||
|
|
||||||
|
PortalQuota quota = portalService.getQuota(tenantId, memberId);
|
||||||
|
|
||||||
|
assertThat(quota.year()).isEqualTo(2026);
|
||||||
|
assertThat(quota.month()).isEqualTo(6);
|
||||||
|
assertThat(quota.dailyUsed()).isEqualByComparingTo("10.0");
|
||||||
|
assertThat(quota.dailyLimit()).isEqualByComparingTo(ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS);
|
||||||
|
assertThat(quota.monthlyUsed()).isEqualByComparingTo("20.0");
|
||||||
|
assertThat(quota.monthlyLimit()).isEqualByComparingTo("50.0");
|
||||||
|
assertThat(quota.isUnder21()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDistributionHistory_returnsPaginatedResults() {
|
||||||
|
UUID batchId = UUID.randomUUID();
|
||||||
|
UUID staffId = UUID.randomUUID();
|
||||||
|
UUID strainId = UUID.randomUUID();
|
||||||
|
|
||||||
|
Distribution dist = new Distribution();
|
||||||
|
dist.setDistributedAt(Instant.now());
|
||||||
|
dist.setBatchId(batchId);
|
||||||
|
dist.setQuantityGrams(new BigDecimal("3.5"));
|
||||||
|
dist.setRecordedBy(staffId);
|
||||||
|
|
||||||
|
Batch batch = new Batch();
|
||||||
|
batch.setStrainId(strainId);
|
||||||
|
Strain strain = new Strain();
|
||||||
|
strain.setName("Blue Dream");
|
||||||
|
User staff = new User();
|
||||||
|
staff.setEmail("staff@club.de");
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(0, 20);
|
||||||
|
when(distributionRepository.findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(dist), pageable, 1));
|
||||||
|
when(batchRepository.findById(batchId)).thenReturn(Optional.of(batch));
|
||||||
|
when(strainRepository.findById(strainId)).thenReturn(Optional.of(strain));
|
||||||
|
when(userRepository.findById(staffId)).thenReturn(Optional.of(staff));
|
||||||
|
|
||||||
|
PortalDistributionHistory history = portalService.getDistributionHistory(tenantId, memberId, pageable);
|
||||||
|
|
||||||
|
assertThat(history.totalElements()).isEqualTo(1);
|
||||||
|
assertThat(history.page()).isEqualTo(0);
|
||||||
|
assertThat(history.distributions()).hasSize(1);
|
||||||
|
assertThat(history.distributions().getFirst().strainName()).isEqualTo("Blue Dream");
|
||||||
|
assertThat(history.distributions().getFirst().grams()).isEqualByComparingTo("3.5");
|
||||||
|
assertThat(history.distributions().getFirst().staffName()).isEqualTo("staff@club.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDashboard_memberOnlySeesOwnData() {
|
||||||
|
// A different memberId — service uses the passed memberId, not some other lookup
|
||||||
|
UUID otherMemberId = UUID.randomUUID();
|
||||||
|
Member otherMember = new Member();
|
||||||
|
otherMember.setFirstName("Anna");
|
||||||
|
otherMember.setLastName("Schmidt");
|
||||||
|
otherMember.setMembershipNumber("CM-002");
|
||||||
|
otherMember.setMembershipDate(LocalDate.of(2025, 3, 1));
|
||||||
|
otherMember.setStatus(MemberStatus.ACTIVE);
|
||||||
|
otherMember.setEmail("anna@example.com");
|
||||||
|
otherMember.setUnder21(true);
|
||||||
|
|
||||||
|
when(memberRepository.findById(otherMemberId)).thenReturn(Optional.of(otherMember));
|
||||||
|
when(complianceService.getQuotaStatus(otherMemberId)).thenReturn(
|
||||||
|
new QuotaStatus(new BigDecimal("30.0"), new BigDecimal("5.0"),
|
||||||
|
new BigDecimal("25.0"), true, 2026, 6));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(eq(otherMemberId), any(), any()))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
when(distributionRepository.findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(otherMemberId, tenantId))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
PortalDashboard dashboard = portalService.getDashboard(tenantId, otherMemberId);
|
||||||
|
|
||||||
|
// Verifies it returns ANNA's data, not MAX's — proving member-scoped isolation
|
||||||
|
assertThat(dashboard.memberName()).isEqualTo("Anna Schmidt");
|
||||||
|
assertThat(dashboard.membershipNumber()).isEqualTo("CM-002");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user