feat(sprint-3): Phase 5 — member portal (session-based auth)
This commit is contained in:
@@ -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;
|
||||
|
||||
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<Distribution, UUID
|
||||
@Query("SELECT d FROM Distribution d WHERE d.tenantId = :tenantId AND d.memberId = :memberId " +
|
||||
"ORDER BY d.distributedAt DESC LIMIT 1")
|
||||
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