diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/PortalController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/PortalController.java new file mode 100644 index 0000000..f9126f6 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/PortalController.java @@ -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 dashboard(@AuthenticationPrincipal PortalPrincipal principal) { + PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId()); + return ResponseEntity.ok(dashboard); + } + + /** + * Member's own profile. + */ + @GetMapping("/me") + public ResponseEntity 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 quota(@AuthenticationPrincipal PortalPrincipal principal) { + PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId()); + return ResponseEntity.ok(quota); + } + + /** + * Own distribution history, paginated. + */ + @GetMapping("/distributions") + public ResponseEntity 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); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalPrincipal.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalPrincipal.java new file mode 100644 index 0000000..1f6cb07 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalPrincipal.java @@ -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 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; + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalUserDetailsService.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalUserDetailsService.java new file mode 100644 index 0000000..f8a30f8 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/PortalUserDetailsService.java @@ -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() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java index 7fee673..2be1109 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -12,10 +12,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; 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. - * Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service). + * Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions. + * Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal). */ @Configuration @EnableWebSecurity @@ -24,6 +27,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; + private final PortalUserDetailsService portalUserDetailsService; /** * 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 @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 { http .securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health") diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index e1e5033..28f7b04 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -30,3 +30,9 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de} # App base URL (for invite links) 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} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java new file mode 100644 index 0000000..f25f29f --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java @@ -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)); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PortalService.java b/cannamanage-service/src/main/java/de/cannamanage/service/PortalService.java new file mode 100644 index 0000000..75e468d --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PortalService.java @@ -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 recent = distributionRepository + .findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId); + + List 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 page = distributionRepository + .findByMemberIdAndTenantIdOrderByDistributedAtDesc(memberId, tenantId, pageable); + + List 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"); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDashboard.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDashboard.java new file mode 100644 index 0000000..493b8f4 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDashboard.java @@ -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 recentDistributions +) { + public record RecentDistribution( + Instant date, + String strainName, + BigDecimal grams, + String staffName + ) {} +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDistributionHistory.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDistributionHistory.java new file mode 100644 index 0000000..d052aae --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalDistributionHistory.java @@ -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 distributions, + int page, + int totalPages, + long totalElements +) { + public record DistributionEntry( + Instant date, + String strainName, + BigDecimal grams, + String staffName + ) {} +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalProfile.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalProfile.java new file mode 100644 index 0000000..83f7733 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalProfile.java @@ -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 +) {} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalQuota.java b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalQuota.java new file mode 100644 index 0000000..642824e --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/dto/portal/PortalQuota.java @@ -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 +) {} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java index f16391a..20b248b 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/DistributionRepository.java @@ -1,6 +1,8 @@ package de.cannamanage.service.repository; 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.Query; import org.springframework.data.repository.query.Param; @@ -52,4 +54,14 @@ public interface DistributionRepository extends JpaRepository findTop5ByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId); + + /** + * Paginated distribution history for a member, newest first (portal history). + */ + Page findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable); } diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/PortalServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/PortalServiceTest.java new file mode 100644 index 0000000..bb29db2 --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/PortalServiceTest.java @@ -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"); + } +}