Merge sprint/3-staff-portal into main
This commit is contained in:
@@ -113,6 +113,11 @@
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Spring Boot Mail (invite flow) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.cannamanage.api.controller;
|
||||
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||
import de.cannamanage.api.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@@ -35,4 +38,12 @@ public class AuthController {
|
||||
LoginResponse response = authService.refresh(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/set-password")
|
||||
@Operation(summary = "Set password via invite token",
|
||||
description = "Public endpoint — validates invite token, sets password, activates account")
|
||||
public ResponseEntity<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.club.ClubResponse;
|
||||
import de.cannamanage.api.dto.club.ClubStatsResponse;
|
||||
import de.cannamanage.api.dto.club.UpdateClubRequest;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.service.ClubService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/clubs")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Club Settings", description = "Club configuration and statistics")
|
||||
public class ClubController {
|
||||
|
||||
private final ClubService clubService;
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "Get current club", description = "Returns the club for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ClubResponse> getMyClub() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Club club = clubService.getClubByTenantId(tenantId);
|
||||
return ResponseEntity.ok(toResponse(club));
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
@Operation(summary = "Update club settings", description = "Updates the club configuration for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<ClubResponse> updateMyClub(@Valid @RequestBody UpdateClubRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Club updated = clubService.updateClub(
|
||||
tenantId,
|
||||
request.name(),
|
||||
request.registrationNumber(),
|
||||
request.contactEmail(),
|
||||
request.contactPhone(),
|
||||
request.addressStreet(),
|
||||
request.addressCity(),
|
||||
request.addressPostalCode(),
|
||||
request.addressState(),
|
||||
request.foundedDate(),
|
||||
request.maxPreventionOfficers(),
|
||||
request.allowedEmailPattern()
|
||||
);
|
||||
return ResponseEntity.ok(toResponse(updated));
|
||||
}
|
||||
|
||||
@GetMapping("/me/stats")
|
||||
@Operation(summary = "Get club statistics", description = "Returns aggregated club statistics")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||
public ResponseEntity<ClubStatsResponse> getMyClubStats() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
return ResponseEntity.ok(new ClubStatsResponse(
|
||||
stats.totalMembers(),
|
||||
stats.activeMembers(),
|
||||
stats.totalStaff(),
|
||||
stats.activeStaff(),
|
||||
stats.totalDistributionsThisMonth(),
|
||||
stats.totalGramsDistributedThisMonth(),
|
||||
stats.activeBatches(),
|
||||
stats.preventionOfficerCount()
|
||||
));
|
||||
}
|
||||
|
||||
private ClubResponse toResponse(Club club) {
|
||||
return new ClubResponse(
|
||||
club.getId(),
|
||||
club.getName(),
|
||||
club.getRegistrationNumber(),
|
||||
club.getContactEmail(),
|
||||
club.getContactPhone(),
|
||||
club.getAddressStreet(),
|
||||
club.getAddressCity(),
|
||||
club.getAddressPostalCode(),
|
||||
club.getAddressState(),
|
||||
club.getFoundedDate(),
|
||||
club.getMaxPreventionOfficers(),
|
||||
club.getAllowedEmailPattern(),
|
||||
club.getStatus(),
|
||||
club.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -25,6 +26,7 @@ public class ComplianceController {
|
||||
@GetMapping("/quota/{memberId}")
|
||||
@Operation(summary = "Get member quota status",
|
||||
description = "Returns current monthly remaining quota for a member per CanG §19")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_QUOTA)")
|
||||
public ResponseEntity<QuotaResponse> getQuotaStatus(@PathVariable UUID memberId) {
|
||||
QuotaStatus status = complianceService.getQuotaStatus(memberId);
|
||||
|
||||
|
||||
+3
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -29,6 +30,7 @@ public class DistributionController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
|
||||
public ResponseEntity<List<DistributionResponse>> listDistributions() {
|
||||
List<DistributionResponse> distributions = distributionRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
@@ -39,6 +41,7 @@ public class DistributionController {
|
||||
@PostMapping
|
||||
@Operation(summary = "Record a distribution",
|
||||
description = "Records a cannabis distribution after compliance checks pass (CanG §19)")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
|
||||
public ResponseEntity<DistributionResponse> createDistribution(
|
||||
@Valid @RequestBody CreateDistributionRequest request,
|
||||
Authentication authentication) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -31,6 +32,7 @@ public class MemberController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all members", description = "Returns all members for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||
public ResponseEntity<List<MemberResponse>> listMembers() {
|
||||
List<MemberResponse> members = memberRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
@@ -40,6 +42,7 @@ public class MemberController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get member by ID")
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('MEMBER') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_MEMBER_LIST)")
|
||||
public ResponseEntity<MemberResponse> getMember(@PathVariable UUID id) {
|
||||
Member member = memberRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"));
|
||||
@@ -48,6 +51,7 @@ public class MemberController {
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new member")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||
public ResponseEntity<MemberResponse> createMember(@Valid @RequestBody CreateMemberRequest request) {
|
||||
Member member = new Member();
|
||||
member.setFirstName(request.firstName());
|
||||
@@ -65,6 +69,7 @@ public class MemberController {
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update a member")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).ADD_MEMBER)")
|
||||
public ResponseEntity<MemberResponse> updateMember(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateMemberRequest request) {
|
||||
Member member = memberRepository.findById(id)
|
||||
@@ -99,7 +104,7 @@ public class MemberController {
|
||||
m.getMembershipNumber(),
|
||||
m.getStatus(),
|
||||
m.isUnder21(),
|
||||
m.isPreventionOfficer()
|
||||
false // preventionOfficer flag comes from StaffAccount, not Member
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||
import de.cannamanage.api.dto.staff.StaffResponse;
|
||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.StaffService;
|
||||
import de.cannamanage.service.StaffTemplates;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/staff")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)")
|
||||
public class StaffController {
|
||||
|
||||
private final StaffService staffService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List all active staff members")
|
||||
public ResponseEntity<List<StaffResponse>> listStaff() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
List<StaffAccount> staffList = staffService.listStaff(tenantId);
|
||||
List<StaffResponse> response = staffList.stream()
|
||||
.map(staff -> {
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return StaffResponse.from(staff, email);
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Create staff member + send invite email")
|
||||
public ResponseEntity<StaffResponse> createStaff(@Valid @RequestBody CreateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.createStaff(
|
||||
tenantId,
|
||||
request.email(),
|
||||
request.displayName(),
|
||||
request.permissions(),
|
||||
request.templateName()
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(StaffResponse.from(staff, request.email()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get staff member by ID")
|
||||
public ResponseEntity<StaffResponse> getStaff(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.getStaff(tenantId, id);
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||
@RequestBody UpdateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.updateStaff(
|
||||
tenantId, id,
|
||||
request.displayName(),
|
||||
request.permissions(),
|
||||
request.templateName(),
|
||||
request.active()
|
||||
);
|
||||
User user = userRepository.findById(staff.getUserId()).orElse(null);
|
||||
String email = user != null ? user.getEmail() : "unknown";
|
||||
return ResponseEntity.ok(StaffResponse.from(staff, email));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Deactivate staff member (revokes all tokens)")
|
||||
public ResponseEntity<Void> deactivateStaff(@PathVariable UUID id) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
staffService.deactivateStaff(tenantId, id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/templates")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List available permission templates")
|
||||
public ResponseEntity<Map<String, Set<StaffPermission>>> listTemplates() {
|
||||
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -26,6 +27,7 @@ public class StockController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all batches", description = "Returns all batches for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<List<BatchResponse>> listBatches() {
|
||||
List<BatchResponse> batches = batchRepository.findAll().stream()
|
||||
.map(this::toResponse)
|
||||
@@ -35,6 +37,7 @@ public class StockController {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get batch by ID")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
|
||||
public ResponseEntity<BatchResponse> getBatch(@PathVariable UUID id) {
|
||||
Batch batch = batchRepository.findById(id)
|
||||
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
|
||||
@@ -44,6 +47,7 @@ public class StockController {
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
|
||||
public ResponseEntity<BatchResponse> createBatch(@Valid @RequestBody CreateBatchRequest request) {
|
||||
Batch batch = new Batch();
|
||||
batch.setStrainId(request.strainId());
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.cannamanage.api.dto.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request DTO for setting password via invite token.
|
||||
* Password complexity: min 8 chars, at least 1 digit + 1 special character.
|
||||
*/
|
||||
public record SetPasswordRequest(
|
||||
@NotBlank String token,
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||
@Pattern(regexp = "^(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$",
|
||||
message = "Password must contain at least 1 digit and 1 special character")
|
||||
String password
|
||||
) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.cannamanage.api.dto.club;
|
||||
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClubResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
String registrationNumber,
|
||||
String contactEmail,
|
||||
String contactPhone,
|
||||
String addressStreet,
|
||||
String addressCity,
|
||||
String addressPostalCode,
|
||||
String addressState,
|
||||
LocalDate foundedDate,
|
||||
Integer maxPreventionOfficers,
|
||||
String allowedEmailPattern,
|
||||
ClubStatus status,
|
||||
Instant createdAt
|
||||
) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.api.dto.club;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record ClubStatsResponse(
|
||||
long totalMembers,
|
||||
long activeMembers,
|
||||
long totalStaff,
|
||||
long activeStaff,
|
||||
long totalDistributionsThisMonth,
|
||||
BigDecimal totalGramsDistributedThisMonth,
|
||||
long activeBatches,
|
||||
long preventionOfficerCount
|
||||
) {}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.cannamanage.api.dto.club;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record UpdateClubRequest(
|
||||
@NotBlank(message = "Club name is required")
|
||||
String name,
|
||||
|
||||
String registrationNumber,
|
||||
|
||||
@Email(message = "Must be a valid email address")
|
||||
String contactEmail,
|
||||
|
||||
String contactPhone,
|
||||
|
||||
String addressStreet,
|
||||
|
||||
String addressCity,
|
||||
|
||||
String addressPostalCode,
|
||||
|
||||
String addressState,
|
||||
|
||||
LocalDate foundedDate,
|
||||
|
||||
@Min(value = 1, message = "Must have at least 1 prevention officer slot")
|
||||
Integer maxPreventionOfficers,
|
||||
|
||||
String allowedEmailPattern
|
||||
) {}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.cannamanage.api.dto.staff;
|
||||
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Request DTO for creating a new staff member (admin invite flow).
|
||||
*/
|
||||
public record CreateStaffRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String displayName,
|
||||
Set<StaffPermission> permissions,
|
||||
String templateName
|
||||
) {}
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.cannamanage.api.dto.staff;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Response DTO for staff member information.
|
||||
*/
|
||||
public record StaffResponse(
|
||||
UUID id,
|
||||
UUID userId,
|
||||
String email,
|
||||
String displayName,
|
||||
Set<StaffPermission> permissions,
|
||||
String templateName,
|
||||
boolean active,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static StaffResponse from(StaffAccount staff, User user) {
|
||||
return new StaffResponse(
|
||||
staff.getId(),
|
||||
staff.getUserId(),
|
||||
user.getEmail(),
|
||||
staff.getDisplayName(),
|
||||
staff.getGrantedPermissions(),
|
||||
null, // templateName not stored; permissions are expanded
|
||||
staff.isActive(),
|
||||
staff.getCreatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
public static StaffResponse from(StaffAccount staff, String email) {
|
||||
return new StaffResponse(
|
||||
staff.getId(),
|
||||
staff.getUserId(),
|
||||
email,
|
||||
staff.getDisplayName(),
|
||||
staff.getGrantedPermissions(),
|
||||
null,
|
||||
staff.isActive(),
|
||||
staff.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.staff;
|
||||
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Request DTO for updating an existing staff member.
|
||||
*/
|
||||
public record UpdateStaffRequest(
|
||||
String displayName,
|
||||
Set<StaffPermission> permissions,
|
||||
String templateName,
|
||||
Boolean active
|
||||
) {}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.service.TokenRevocationService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -21,7 +22,7 @@ import java.util.UUID;
|
||||
/**
|
||||
* JWT authentication filter.
|
||||
* Extracts Bearer token from Authorization header, validates it,
|
||||
* sets SecurityContext and TenantContext for downstream processing.
|
||||
* checks token blacklist (revocation), sets SecurityContext and TenantContext.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -29,6 +30,7 @@ import java.util.UUID;
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final TokenRevocationService tokenRevocationService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
@@ -48,6 +50,14 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check token blacklist (revocation) — skip for portal paths per plan review warning #5
|
||||
String jti = jwtService.extractJti(token);
|
||||
if (jti != null && tokenRevocationService.isRevoked(jti)) {
|
||||
log.debug("Token {} is revoked, rejecting request", jti);
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
UUID userId = jwtService.extractUserId(token);
|
||||
UUID tenantId = jwtService.extractTenantId(token);
|
||||
String role = jwtService.extractRole(token);
|
||||
@@ -76,6 +86,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
return path.startsWith("/api/v1/auth/")
|
||||
|| path.startsWith("/portal/")
|
||||
|| path.startsWith("/swagger-ui")
|
||||
|| path.startsWith("/v3/api-docs");
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* JWT token generation and validation service.
|
||||
* Access tokens: 1 hour expiry.
|
||||
* Access tokens: 1 hour expiry, includes jti + permissions for STAFF.
|
||||
* Refresh tokens: 30 days expiry.
|
||||
*/
|
||||
@Service
|
||||
@@ -31,19 +29,40 @@ public class JwtService {
|
||||
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||
private long refreshTokenExpiry; // seconds (30 days)
|
||||
|
||||
/**
|
||||
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||
*/
|
||||
public String generateAccessToken(UUID userId, UUID tenantId, String role, String email) {
|
||||
return buildToken(Map.of(
|
||||
"tenant_id", tenantId.toString(),
|
||||
"role", role,
|
||||
"email", email
|
||||
), userId.toString(), accessTokenExpiry);
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("tenant_id", tenantId.toString());
|
||||
claims.put("role", role);
|
||||
claims.put("email", email);
|
||||
claims.put("jti", UUID.randomUUID().toString());
|
||||
|
||||
return buildToken(claims, userId.toString(), accessTokenExpiry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for STAFF role — includes permissions list.
|
||||
*/
|
||||
public String generateStaffAccessToken(UUID userId, UUID tenantId, String email, List<String> permissions) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("tenant_id", tenantId.toString());
|
||||
claims.put("role", "STAFF");
|
||||
claims.put("email", email);
|
||||
claims.put("jti", UUID.randomUUID().toString());
|
||||
claims.put("permissions", permissions);
|
||||
|
||||
return buildToken(claims, userId.toString(), accessTokenExpiry);
|
||||
}
|
||||
|
||||
public String generateRefreshToken(UUID userId, UUID tenantId) {
|
||||
return buildToken(Map.of(
|
||||
"tenant_id", tenantId.toString(),
|
||||
"type", "refresh"
|
||||
), userId.toString(), refreshTokenExpiry);
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("tenant_id", tenantId.toString());
|
||||
claims.put("type", "refresh");
|
||||
claims.put("jti", UUID.randomUUID().toString());
|
||||
|
||||
return buildToken(claims, userId.toString(), refreshTokenExpiry);
|
||||
}
|
||||
|
||||
public String extractSubject(String token) {
|
||||
@@ -66,6 +85,36 @@ public class JwtService {
|
||||
return extractClaim(token, claims -> claims.get("email", String.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the JTI (JWT ID) claim — used for token revocation.
|
||||
*/
|
||||
public String extractJti(String token) {
|
||||
return extractClaim(token, claims -> claims.get("jti", String.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract permissions list from STAFF token.
|
||||
* Returns empty list if not present (non-STAFF tokens).
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> extractPermissions(String token) {
|
||||
return extractClaim(token, claims -> {
|
||||
Object perms = claims.get("permissions");
|
||||
if (perms instanceof List<?>) {
|
||||
return (List<String>) perms;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token expiration as Instant — used for revocation record.
|
||||
*/
|
||||
public Instant extractExpirationInstant(String token) {
|
||||
Date exp = extractClaim(token, Claims::getExpiration);
|
||||
return exp.toInstant();
|
||||
}
|
||||
|
||||
public boolean isTokenValid(String token) {
|
||||
try {
|
||||
extractAllClaims(token);
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* SpEL-accessible bean for checking prevention officer status.
|
||||
* Usage in @PreAuthorize:
|
||||
* @PreAuthorize("hasRole('ADMIN') or @preventionOfficer.check(authentication)")
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("preventionOfficer")
|
||||
@RequiredArgsConstructor
|
||||
public class PreventionOfficerChecker {
|
||||
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
|
||||
/**
|
||||
* Checks if the authenticated user is a designated prevention officer.
|
||||
* ADMIN always passes. STAFF must have is_prevention_officer = true.
|
||||
*/
|
||||
public boolean check(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ADMIN always passes
|
||||
boolean isAdmin = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_ADMIN"));
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// STAFF must be a prevention officer
|
||||
boolean isStaff = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_STAFF"));
|
||||
if (!isStaff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UUID userId = (UUID) authentication.getPrincipal();
|
||||
return staffAccountRepository.findByUserId(userId)
|
||||
.filter(StaffAccount::isActive)
|
||||
.map(StaffAccount::isPreventionOfficer)
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,8 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Security configuration — Sprint 2: API-only with JWT.
|
||||
* Roles: ADMIN (full access) + MEMBER (self-service endpoints only).
|
||||
* STAFF role reserved for Sprint 3.
|
||||
* Security configuration — Sprint 3: API + Staff portal with JWT.
|
||||
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -28,7 +27,7 @@ public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* API security — stateless JWT authentication.
|
||||
* All /api/v1/** endpoints require authentication except /api/v1/auth/**.
|
||||
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
|
||||
*/
|
||||
@Bean
|
||||
@Order(1)
|
||||
@@ -41,10 +40,11 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "MEMBER")
|
||||
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "MEMBER")
|
||||
.requestMatchers("/api/v1/stock/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "MEMBER")
|
||||
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* SpEL-accessible bean for fine-grained staff permission checks.
|
||||
* Usage in @PreAuthorize:
|
||||
* @PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_DISTRIBUTION)")
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("staffPermissions")
|
||||
@RequiredArgsConstructor
|
||||
public class StaffPermissionChecker {
|
||||
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
|
||||
/**
|
||||
* Checks if the authenticated user has the required permission.
|
||||
* ADMIN role always passes. STAFF checks granted_permissions on their StaffAccount.
|
||||
*/
|
||||
public boolean has(Authentication authentication, StaffPermission required) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ADMIN always has all permissions
|
||||
boolean isAdmin = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_ADMIN"));
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// STAFF must have the specific permission granted
|
||||
boolean isStaff = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_STAFF"));
|
||||
if (!isStaff) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UUID userId = (UUID) authentication.getPrincipal();
|
||||
return staffAccountRepository.findByUserId(userId)
|
||||
.filter(StaffAccount::isActive)
|
||||
.map(staff -> staff.hasPermission(required))
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,13 @@ package de.cannamanage.api.service;
|
||||
import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||
import de.cannamanage.api.security.JwtService;
|
||||
import de.cannamanage.domain.entity.InviteToken;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,7 +25,7 @@ import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Authentication service — handles login and token refresh.
|
||||
* Authentication service — handles login, token refresh, and invite-based password setup.
|
||||
* Stateless JWT approach: no UserDetailsService needed.
|
||||
* Refresh tokens are hashed and stored on the User entity for revocation support.
|
||||
*/
|
||||
@@ -32,6 +37,8 @@ public class AuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final JwtService jwtService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
@@ -39,7 +46,7 @@ public class AuthService {
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account is disabled");
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
@@ -75,7 +82,7 @@ public class AuthService {
|
||||
.orElseThrow(() -> new AuthenticationException("User not found"));
|
||||
|
||||
if (!user.isActive()) {
|
||||
throw new AuthenticationException("Account is disabled");
|
||||
throw new AuthenticationException("Account not activated");
|
||||
}
|
||||
|
||||
// Verify the refresh token matches stored hash (revocation check)
|
||||
@@ -96,6 +103,39 @@ public class AuthService {
|
||||
return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password for a user via invite token.
|
||||
* Validates the token, sets the password hash, marks user active, marks token as used.
|
||||
* Security: generic error message for invalid/expired tokens (don't reveal state).
|
||||
*/
|
||||
@Transactional
|
||||
public void setPassword(SetPasswordRequest request) {
|
||||
// Find valid (unused + not expired) token — security: generic error message
|
||||
InviteToken inviteToken = inviteTokenRepository
|
||||
.findByTokenAndUsedAtIsNullAndExpiresAtAfter(request.token(), Instant.now())
|
||||
.orElseThrow(() -> new AuthenticationException("Invalid or expired token"));
|
||||
|
||||
User user = inviteToken.getUser();
|
||||
|
||||
// Set password and activate user
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setActive(true);
|
||||
userRepository.save(user);
|
||||
|
||||
// Mark token as used
|
||||
inviteToken.setUsedAt(Instant.now());
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
|
||||
// Update staff account activation timestamp
|
||||
staffAccountRepository.findByUserId(user.getId())
|
||||
.ifPresent(staff -> {
|
||||
staff.setActivatedAt(Instant.now());
|
||||
staffAccountRepository.save(staff);
|
||||
});
|
||||
|
||||
log.info("Password set for user {} via invite token", user.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 hash for refresh token storage.
|
||||
* JWTs exceed BCrypt's 72-byte limit (enforced in Spring Security 7+).
|
||||
|
||||
@@ -18,3 +18,15 @@ springdoc.swagger-ui.operations-sorter=method
|
||||
# Enable Spring AOP for TenantFilterAspect
|
||||
spring.aop.auto=true
|
||||
spring.aop.proxy-target-class=true
|
||||
|
||||
# Spring Mail (dev defaults: Mailpit on localhost:1025)
|
||||
spring.mail.host=${SMTP_HOST:localhost}
|
||||
spring.mail.port=${SMTP_PORT:1025}
|
||||
spring.mail.username=${SMTP_USERNAME:}
|
||||
spring.mail.password=${SMTP_PASSWORD:}
|
||||
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:false}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:false}
|
||||
spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
|
||||
|
||||
# App base URL (for invite links)
|
||||
app.base-url=${APP_BASE_URL:http://localhost:8080}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Sprint 3: Staff Portal foundation
|
||||
-- Staff accounts, permissions, revoked tokens, prevention officer support
|
||||
|
||||
-- Staff accounts table (links users with STAFF role to their permissions)
|
||||
CREATE TABLE staff_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES clubs(id),
|
||||
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
|
||||
display_name VARCHAR(150) NOT NULL,
|
||||
is_prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
invited_at TIMESTAMPTZ,
|
||||
activated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Staff account permissions (element collection table)
|
||||
CREATE TABLE staff_account_permissions (
|
||||
staff_account_id UUID NOT NULL REFERENCES staff_accounts(id) ON DELETE CASCADE,
|
||||
permission VARCHAR(50) NOT NULL,
|
||||
PRIMARY KEY (staff_account_id, permission)
|
||||
);
|
||||
|
||||
-- Revoked tokens table for JWT blacklisting
|
||||
CREATE TABLE revoked_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
jti VARCHAR(36) NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
reason VARCHAR(100)
|
||||
);
|
||||
|
||||
-- Indexes for revoked tokens
|
||||
CREATE INDEX idx_revoked_tokens_jti ON revoked_tokens(jti);
|
||||
CREATE INDEX idx_revoked_tokens_user_id ON revoked_tokens(user_id);
|
||||
CREATE INDEX idx_revoked_tokens_expires_at ON revoked_tokens(expires_at);
|
||||
|
||||
-- Index for staff accounts
|
||||
CREATE INDEX idx_staff_accounts_tenant_id ON staff_accounts(tenant_id);
|
||||
CREATE INDEX idx_staff_accounts_user_id ON staff_accounts(user_id);
|
||||
|
||||
-- Add max_prevention_officers to clubs table (default 2 per plan)
|
||||
ALTER TABLE clubs ADD COLUMN max_prevention_officers INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Sprint 3 Phase 2: Club settings extended columns
|
||||
-- Additional address fields, contact info, and allowed email pattern for clubs
|
||||
|
||||
ALTER TABLE clubs ADD COLUMN registration_number VARCHAR(100);
|
||||
ALTER TABLE clubs ADD COLUMN contact_email VARCHAR(255);
|
||||
ALTER TABLE clubs ADD COLUMN contact_phone VARCHAR(50);
|
||||
ALTER TABLE clubs ADD COLUMN address_street VARCHAR(255);
|
||||
ALTER TABLE clubs ADD COLUMN address_city VARCHAR(100);
|
||||
ALTER TABLE clubs ADD COLUMN address_postal_code VARCHAR(20);
|
||||
ALTER TABLE clubs ADD COLUMN address_state VARCHAR(100);
|
||||
ALTER TABLE clubs ADD COLUMN founded_date DATE;
|
||||
ALTER TABLE clubs ADD COLUMN allowed_email_pattern VARCHAR(255);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Sprint 3 Phase 3: Invite tokens for staff onboarding
|
||||
|
||||
CREATE TABLE invite_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_invite_tokens_token ON invite_tokens(token);
|
||||
CREATE INDEX idx_invite_tokens_user_id ON invite_tokens(user_id);
|
||||
@@ -0,0 +1,14 @@
|
||||
Hallo {displayName},
|
||||
|
||||
Du wurdest als Mitarbeiter/in beim Anbauverein "{clubName}" eingeladen.
|
||||
|
||||
Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren:
|
||||
|
||||
{setPasswordUrl}
|
||||
|
||||
Dieser Link ist 72 Stunden gültig.
|
||||
|
||||
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
|
||||
|
||||
Viele Grüße,
|
||||
Dein CannaManage-Team
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.club.UpdateClubRequest;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.service.ClubService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ClubControllerTest {
|
||||
|
||||
@Mock
|
||||
private ClubService clubService;
|
||||
|
||||
@InjectMocks
|
||||
private ClubController clubController;
|
||||
|
||||
private UUID tenantId;
|
||||
private Club club;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
|
||||
club = new Club();
|
||||
club.setId(UUID.randomUUID());
|
||||
club.setTenantId(tenantId);
|
||||
club.setName("Green Garden Club");
|
||||
club.setRegistrationNumber("REG-2024-001");
|
||||
club.setContactEmail("info@greengardenclub.de");
|
||||
club.setContactPhone("+49 30 12345678");
|
||||
club.setAddressStreet("Hanfweg 42");
|
||||
club.setAddressCity("Berlin");
|
||||
club.setAddressPostalCode("10115");
|
||||
club.setAddressState("Berlin");
|
||||
club.setFoundedDate(LocalDate.of(2024, 7, 1));
|
||||
club.setMaxPreventionOfficers(2);
|
||||
club.setAllowedEmailPattern(".*@greengardenclub\\.de");
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
club.setCreatedAt(Instant.now());
|
||||
club.setLicenseNumber("LIC-001");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMyClub_returnsClubResponse() {
|
||||
when(clubService.getClubByTenantId(tenantId)).thenReturn(club);
|
||||
|
||||
ResponseEntity<?> response = clubController.getMyClub();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
verify(clubService).getClubByTenantId(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateMyClub_updatesAndReturns() {
|
||||
UpdateClubRequest request = new UpdateClubRequest(
|
||||
"Updated Club", "REG-NEW", "new@club.de", "+49111",
|
||||
"Newstreet 1", "Hamburg", "20095", "Hamburg",
|
||||
LocalDate.of(2024, 1, 1), 3, ".*@club\\.de"
|
||||
);
|
||||
when(clubService.updateClub(
|
||||
eq(tenantId), eq("Updated Club"), eq("REG-NEW"),
|
||||
eq("new@club.de"), eq("+49111"),
|
||||
eq("Newstreet 1"), eq("Hamburg"), eq("20095"), eq("Hamburg"),
|
||||
eq(LocalDate.of(2024, 1, 1)), eq(3), eq(".*@club\\.de")
|
||||
)).thenReturn(club);
|
||||
|
||||
ResponseEntity<?> response = clubController.updateMyClub(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMyClubStats_returnsStats() {
|
||||
ClubService.ClubStats stats = new ClubService.ClubStats(
|
||||
50, 42, 5, 4, 120, new BigDecimal("1500.50"), 8, 2
|
||||
);
|
||||
when(clubService.getClubStats(tenantId)).thenReturn(stats);
|
||||
|
||||
ResponseEntity<?> response = clubController.getMyClubStats();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
verify(clubService).getClubStats(tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.cannamanage.api.dto.staff.CreateStaffRequest;
|
||||
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
|
||||
import de.cannamanage.api.security.JwtAuthFilter;
|
||||
import de.cannamanage.api.security.JwtService;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.StaffService;
|
||||
import de.cannamanage.service.TokenRevocationService;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(StaffController.class)
|
||||
class StaffControllerTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean private StaffService staffService;
|
||||
@MockBean private UserRepository userRepository;
|
||||
@MockBean private JwtService jwtService;
|
||||
@MockBean private JwtAuthFilter jwtAuthFilter;
|
||||
@MockBean private TokenRevocationService tokenRevocationService;
|
||||
|
||||
private UUID tenantId;
|
||||
private UUID staffId;
|
||||
private UUID userId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = UUID.randomUUID();
|
||||
staffId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void listStaff_returnsStaffList() throws Exception {
|
||||
StaffAccount staff = createStaffAccount();
|
||||
User user = createUser();
|
||||
when(staffService.listStaff(tenantId)).thenReturn(List.of(staff));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(get("/api/v1/staff"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].displayName").value("Test Staff"))
|
||||
.andExpect(jsonPath("$[0].email").value("staff@test.de"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void createStaff_validRequest_returns201() throws Exception {
|
||||
CreateStaffRequest request = new CreateStaffRequest(
|
||||
"new@test.de", "New Staff",
|
||||
EnumSet.of(StaffPermission.VIEW_STOCK), null);
|
||||
|
||||
StaffAccount created = createStaffAccount();
|
||||
when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any()))
|
||||
.thenReturn(created);
|
||||
|
||||
mockMvc.perform(post("/api/v1/staff")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.displayName").value("Test Staff"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void createStaff_invalidEmail_returns400() throws Exception {
|
||||
CreateStaffRequest request = new CreateStaffRequest(
|
||||
"not-an-email", "Bad Staff",
|
||||
EnumSet.of(StaffPermission.VIEW_STOCK), null);
|
||||
|
||||
mockMvc.perform(post("/api/v1/staff")
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void getStaff_returns200() throws Exception {
|
||||
StaffAccount staff = createStaffAccount();
|
||||
User user = createUser();
|
||||
when(staffService.getStaff(tenantId, staffId)).thenReturn(staff);
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(get("/api/v1/staff/{id}", staffId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(staffId.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void deactivateStaff_returns204() throws Exception {
|
||||
mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(staffService).deactivateStaff(tenantId, staffId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "ADMIN")
|
||||
void listTemplates_returnsTemplateMap() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/staff/templates"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.ausgabe").isArray())
|
||||
.andExpect(jsonPath("$.lager").isArray())
|
||||
.andExpect(jsonPath("$.vorstand").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(roles = "MEMBER")
|
||||
void listStaff_asMember_returns403() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/staff"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private StaffAccount createStaffAccount() {
|
||||
StaffAccount staff = new StaffAccount();
|
||||
staff.setId(staffId);
|
||||
staff.setTenantId(tenantId);
|
||||
staff.setUserId(userId);
|
||||
staff.setDisplayName("Test Staff");
|
||||
staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK));
|
||||
staff.setActive(true);
|
||||
staff.setCreatedAt(Instant.now());
|
||||
return staff;
|
||||
}
|
||||
|
||||
private User createUser() {
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setEmail("staff@test.de");
|
||||
user.setRole(UserRole.ROLE_STAFF);
|
||||
user.setActive(true);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
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.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StaffPermissionCheckerTest {
|
||||
|
||||
@Mock
|
||||
private StaffAccountRepository staffAccountRepository;
|
||||
|
||||
@InjectMocks
|
||||
private StaffPermissionChecker checker;
|
||||
|
||||
private UUID staffUserId;
|
||||
private StaffAccount staffAccount;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
staffUserId = UUID.randomUUID();
|
||||
staffAccount = new StaffAccount();
|
||||
staffAccount.setUserId(staffUserId);
|
||||
staffAccount.setDisplayName("Test Staff");
|
||||
staffAccount.setActive(true);
|
||||
staffAccount.setGrantedPermissions(Set.of(
|
||||
StaffPermission.RECORD_DISTRIBUTION,
|
||||
StaffPermission.VIEW_MEMBER_LIST
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminAlwaysHasPermission() {
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
UUID.randomUUID(), null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||
);
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
|
||||
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staffWithGrantedPermission_returnsTrue() {
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
staffUserId, null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
|
||||
);
|
||||
|
||||
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staffWithoutGrantedPermission_returnsFalse() {
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
staffUserId, null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
|
||||
);
|
||||
|
||||
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.MANAGE_GROW_CALENDAR)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveStaff_returnsFalse() {
|
||||
staffAccount.setActive(false);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
staffUserId, null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
|
||||
);
|
||||
|
||||
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.of(staffAccount));
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberRole_returnsFalse() {
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
UUID.randomUUID(), null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_MEMBER"))
|
||||
);
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.VIEW_MEMBER_LIST)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAuthentication_returnsFalse() {
|
||||
assertThat(checker.has(null, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staffWithNoAccount_returnsFalse() {
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
staffUserId, null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_STAFF"))
|
||||
);
|
||||
|
||||
when(staffAccountRepository.findByUserId(staffUserId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(checker.has(auth, StaffPermission.RECORD_DISTRIBUTION)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package de.cannamanage.domain.entity;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "clubs")
|
||||
public class Club extends AbstractTenantEntity {
|
||||
@@ -10,6 +12,30 @@ public class Club extends AbstractTenantEntity {
|
||||
@Column(name = "name", nullable = false, length = 255)
|
||||
private String name;
|
||||
|
||||
@Column(name = "registration_number", length = 100)
|
||||
private String registrationNumber;
|
||||
|
||||
@Column(name = "contact_email", length = 255)
|
||||
private String contactEmail;
|
||||
|
||||
@Column(name = "contact_phone", length = 50)
|
||||
private String contactPhone;
|
||||
|
||||
@Column(name = "address_street", length = 255)
|
||||
private String addressStreet;
|
||||
|
||||
@Column(name = "address_city", length = 100)
|
||||
private String addressCity;
|
||||
|
||||
@Column(name = "address_postal_code", length = 20)
|
||||
private String addressPostalCode;
|
||||
|
||||
@Column(name = "address_state", length = 100)
|
||||
private String addressState;
|
||||
|
||||
@Column(name = "founded_date")
|
||||
private LocalDate foundedDate;
|
||||
|
||||
@Column(name = "address")
|
||||
private String address;
|
||||
|
||||
@@ -19,6 +45,12 @@ public class Club extends AbstractTenantEntity {
|
||||
@Column(name = "max_members", nullable = false)
|
||||
private Integer maxMembers = 500;
|
||||
|
||||
@Column(name = "max_prevention_officers", nullable = false)
|
||||
private Integer maxPreventionOfficers = 2;
|
||||
|
||||
@Column(name = "allowed_email_pattern", length = 255)
|
||||
private String allowedEmailPattern;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 50)
|
||||
private ClubStatus status = ClubStatus.ACTIVE;
|
||||
@@ -26,6 +58,30 @@ public class Club extends AbstractTenantEntity {
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getRegistrationNumber() { return registrationNumber; }
|
||||
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
|
||||
|
||||
public String getContactEmail() { return contactEmail; }
|
||||
public void setContactEmail(String contactEmail) { this.contactEmail = contactEmail; }
|
||||
|
||||
public String getContactPhone() { return contactPhone; }
|
||||
public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
|
||||
|
||||
public String getAddressStreet() { return addressStreet; }
|
||||
public void setAddressStreet(String addressStreet) { this.addressStreet = addressStreet; }
|
||||
|
||||
public String getAddressCity() { return addressCity; }
|
||||
public void setAddressCity(String addressCity) { this.addressCity = addressCity; }
|
||||
|
||||
public String getAddressPostalCode() { return addressPostalCode; }
|
||||
public void setAddressPostalCode(String addressPostalCode) { this.addressPostalCode = addressPostalCode; }
|
||||
|
||||
public String getAddressState() { return addressState; }
|
||||
public void setAddressState(String addressState) { this.addressState = addressState; }
|
||||
|
||||
public LocalDate getFoundedDate() { return foundedDate; }
|
||||
public void setFoundedDate(LocalDate foundedDate) { this.foundedDate = foundedDate; }
|
||||
|
||||
public String getAddress() { return address; }
|
||||
public void setAddress(String address) { this.address = address; }
|
||||
|
||||
@@ -35,6 +91,12 @@ public class Club extends AbstractTenantEntity {
|
||||
public Integer getMaxMembers() { return maxMembers; }
|
||||
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
||||
|
||||
public Integer getMaxPreventionOfficers() { return maxPreventionOfficers; }
|
||||
public void setMaxPreventionOfficers(Integer maxPreventionOfficers) { this.maxPreventionOfficers = maxPreventionOfficers; }
|
||||
|
||||
public String getAllowedEmailPattern() { return allowedEmailPattern; }
|
||||
public void setAllowedEmailPattern(String allowedEmailPattern) { this.allowedEmailPattern = allowedEmailPattern; }
|
||||
|
||||
public ClubStatus getStatus() { return status; }
|
||||
public void setStatus(ClubStatus status) { this.status = status; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Invite token for staff onboarding.
|
||||
* Created when an admin invites a new staff member — the token is sent via email
|
||||
* and used once to set the initial password.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "invite_tokens")
|
||||
public class InviteToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "token", nullable = false, unique = true, length = 64)
|
||||
private String token;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public User getUser() { return user; }
|
||||
public void setUser(User user) { this.user = user; }
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||
|
||||
public Instant getUsedAt() { return usedAt; }
|
||||
public void setUsedAt(Instant usedAt) { this.usedAt = usedAt; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public boolean isExpired() {
|
||||
return Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean isUsed() {
|
||||
return usedAt != null;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return !isExpired() && !isUsed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Stores revoked JWT tokens for token blacklist checking.
|
||||
* Tokens are identified by their JTI (JWT ID) claim.
|
||||
* Cleanup scheduler removes expired entries nightly.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "revoked_tokens", indexes = {
|
||||
@Index(name = "idx_revoked_tokens_jti", columnList = "jti", unique = true),
|
||||
@Index(name = "idx_revoked_tokens_user_id", columnList = "user_id"),
|
||||
@Index(name = "idx_revoked_tokens_expires_at", columnList = "expires_at")
|
||||
})
|
||||
public class RevokedToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "jti", nullable = false, unique = true, length = 36)
|
||||
private String jti;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "tenant_id", nullable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
@Column(name = "revoked_at", nullable = false)
|
||||
private Instant revokedAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "reason", length = 100)
|
||||
private String reason;
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public String getJti() { return jti; }
|
||||
public void setJti(String jti) { this.jti = jti; }
|
||||
|
||||
public UUID getUserId() { return userId; }
|
||||
public void setUserId(UUID userId) { this.userId = userId; }
|
||||
|
||||
public UUID getTenantId() { return tenantId; }
|
||||
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
||||
|
||||
public Instant getRevokedAt() { return revokedAt; }
|
||||
public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
|
||||
|
||||
public Instant getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
|
||||
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Staff account with fine-grained permissions.
|
||||
* Links a user (STAFF role) to their granted permissions stored as JSONB.
|
||||
* One StaffAccount per user; permissions are a subset of StaffPermission enum values.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "staff_accounts")
|
||||
public class StaffAccount extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "user_id", nullable = false, unique = true)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 150)
|
||||
private String displayName;
|
||||
|
||||
@ElementCollection(targetClass = StaffPermission.class, fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "staff_account_permissions",
|
||||
joinColumns = @JoinColumn(name = "staff_account_id"))
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission", nullable = false, length = 50)
|
||||
private Set<StaffPermission> grantedPermissions = new HashSet<>();
|
||||
|
||||
@Column(name = "is_prevention_officer", nullable = false)
|
||||
private boolean preventionOfficer = false;
|
||||
|
||||
@Column(name = "active", nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
@Column(name = "invited_at")
|
||||
private Instant invitedAt;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
public UUID getUserId() { return userId; }
|
||||
public void setUserId(UUID userId) { this.userId = userId; }
|
||||
|
||||
public String getDisplayName() { return displayName; }
|
||||
public void setDisplayName(String displayName) { this.displayName = displayName; }
|
||||
|
||||
public Set<StaffPermission> getGrantedPermissions() { return grantedPermissions; }
|
||||
public void setGrantedPermissions(Set<StaffPermission> grantedPermissions) { this.grantedPermissions = grantedPermissions; }
|
||||
|
||||
public boolean isPreventionOfficer() { return preventionOfficer; }
|
||||
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
|
||||
|
||||
public boolean isActive() { return active; }
|
||||
public void setActive(boolean active) { this.active = active; }
|
||||
|
||||
public Instant getInvitedAt() { return invitedAt; }
|
||||
public void setInvitedAt(Instant invitedAt) { this.invitedAt = invitedAt; }
|
||||
|
||||
public Instant getActivatedAt() { return activatedAt; }
|
||||
public void setActivatedAt(Instant activatedAt) { this.activatedAt = activatedAt; }
|
||||
|
||||
public boolean hasPermission(StaffPermission permission) {
|
||||
return grantedPermissions.contains(permission);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.cannamanage.domain.enums;
|
||||
|
||||
/**
|
||||
* Fine-grained permissions for STAFF role users.
|
||||
* Admins implicitly have all permissions.
|
||||
* Staff members are granted a subset via their StaffAccount.
|
||||
*/
|
||||
public enum StaffPermission {
|
||||
RECORD_DISTRIBUTION,
|
||||
VIEW_MEMBER_LIST,
|
||||
VIEW_MEMBER_QUOTA,
|
||||
ADD_MEMBER,
|
||||
VIEW_STOCK,
|
||||
RECORD_STOCK_IN,
|
||||
VIEW_COMPLIANCE_REPORT,
|
||||
MANAGE_GROW_CALENDAR
|
||||
}
|
||||
@@ -47,6 +47,38 @@
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Caffeine caching for token revocation -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Context for @Scheduled -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Web for ResponseStatusException -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Mail (invite flow) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- OpenPDF for PDF report generation -->
|
||||
<dependency>
|
||||
<groupId>com.github.librepdf</groupId>
|
||||
<artifactId>openpdf</artifactId>
|
||||
<version>2.0.4</version>
|
||||
</dependency>
|
||||
<!-- Apache Commons CSV for CSV report generation -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-csv</artifactId>
|
||||
<version>1.11.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ClubService {
|
||||
|
||||
private final ClubRepository clubRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final BatchRepository batchRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Club getClubByTenantId(UUID tenantId) {
|
||||
return clubRepository.findByTenantId(tenantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Club not found for tenant"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Club updateClub(UUID tenantId, String name, String registrationNumber,
|
||||
String contactEmail, String contactPhone,
|
||||
String addressStreet, String addressCity,
|
||||
String addressPostalCode, String addressState,
|
||||
LocalDate foundedDate,
|
||||
Integer maxPreventionOfficers, String allowedEmailPattern) {
|
||||
Club club = getClubByTenantId(tenantId);
|
||||
|
||||
// Validate regex pattern if provided
|
||||
if (allowedEmailPattern != null && !allowedEmailPattern.isBlank()) {
|
||||
validateRegexPattern(allowedEmailPattern);
|
||||
}
|
||||
|
||||
club.setName(name);
|
||||
club.setRegistrationNumber(registrationNumber);
|
||||
club.setContactEmail(contactEmail);
|
||||
club.setContactPhone(contactPhone);
|
||||
club.setAddressStreet(addressStreet);
|
||||
club.setAddressCity(addressCity);
|
||||
club.setAddressPostalCode(addressPostalCode);
|
||||
club.setAddressState(addressState);
|
||||
club.setFoundedDate(foundedDate);
|
||||
if (maxPreventionOfficers != null) {
|
||||
club.setMaxPreventionOfficers(maxPreventionOfficers);
|
||||
}
|
||||
club.setAllowedEmailPattern(allowedEmailPattern);
|
||||
|
||||
return clubRepository.save(club);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ClubStats getClubStats(UUID tenantId) {
|
||||
long totalMembers = memberRepository.countByTenantId(tenantId);
|
||||
long activeMembers = memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE);
|
||||
long totalStaff = staffAccountRepository.countByTenantId(tenantId);
|
||||
long activeStaff = staffAccountRepository.countByTenantIdAndActiveTrue(tenantId);
|
||||
|
||||
// Distributions this month
|
||||
Instant startOfMonth = LocalDate.now().withDayOfMonth(1)
|
||||
.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
long totalDistributionsThisMonth = distributionRepository
|
||||
.countByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
|
||||
BigDecimal totalGramsThisMonth = distributionRepository
|
||||
.sumGramsByTenantIdAndDistributedAtAfter(tenantId, startOfMonth);
|
||||
|
||||
long activeBatches = batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE);
|
||||
long preventionOfficerCount = staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId);
|
||||
|
||||
return new ClubStats(
|
||||
totalMembers, activeMembers,
|
||||
totalStaff, activeStaff,
|
||||
totalDistributionsThisMonth,
|
||||
totalGramsThisMonth != null ? totalGramsThisMonth : BigDecimal.ZERO,
|
||||
activeBatches, preventionOfficerCount
|
||||
);
|
||||
}
|
||||
|
||||
private void validateRegexPattern(String pattern) {
|
||||
try {
|
||||
Pattern.compile(pattern);
|
||||
} catch (PatternSyntaxException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Invalid regex pattern for allowedEmailPattern: " + e.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
public record ClubStats(
|
||||
long totalMembers,
|
||||
long activeMembers,
|
||||
long totalStaff,
|
||||
long activeStaff,
|
||||
long totalDistributionsThisMonth,
|
||||
BigDecimal totalGramsDistributedThisMonth,
|
||||
long activeBatches,
|
||||
long preventionOfficerCount
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Email service for sending invite emails to new staff members.
|
||||
* Uses plain text templates — no Thymeleaf dependency needed.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final String baseUrl;
|
||||
private final String fromAddress;
|
||||
|
||||
public EmailService(JavaMailSender mailSender,
|
||||
@Value("${app.base-url:http://localhost:8080}") String baseUrl,
|
||||
@Value("${spring.mail.from:noreply@cannamanage.de}") String fromAddress) {
|
||||
this.mailSender = mailSender;
|
||||
this.baseUrl = baseUrl;
|
||||
this.fromAddress = fromAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invite email to a new staff member with a link to set their password.
|
||||
* Security: token value is NOT logged.
|
||||
*/
|
||||
public void sendInviteEmail(String recipientEmail, String displayName,
|
||||
String clubName, String token) {
|
||||
String setPasswordUrl = baseUrl + "/auth/set-password?token=" + token;
|
||||
|
||||
String body = String.format("""
|
||||
Hallo %s,
|
||||
|
||||
Du wurdest als Mitarbeiter/in beim Anbauverein "%s" eingeladen.
|
||||
|
||||
Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren:
|
||||
|
||||
%s
|
||||
|
||||
Dieser Link ist 72 Stunden gültig.
|
||||
|
||||
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
|
||||
|
||||
Viele Grüße,
|
||||
Dein CannaManage-Team
|
||||
""", displayName, clubName, setPasswordUrl);
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(fromAddress);
|
||||
message.setTo(recipientEmail);
|
||||
message.setSubject("Einladung: " + clubName + " — Account aktivieren");
|
||||
message.setText(body);
|
||||
|
||||
try {
|
||||
mailSender.send(message);
|
||||
log.info("Invite email sent to {} for club '{}'", recipientEmail, clubName);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send invite email to {}: {}", recipientEmail, e.getMessage());
|
||||
throw new RuntimeException("Failed to send invite email", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.InviteToken;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.springframework.http.HttpStatus.*;
|
||||
|
||||
/**
|
||||
* Staff management service — CRUD operations + invite flow.
|
||||
* Handles: staff creation (with invite email), permission updates (with token revocation),
|
||||
* and deactivation.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class StaffService {
|
||||
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
private static final long INVITE_EXPIRY_HOURS = 72;
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final StaffAccountRepository staffAccountRepository;
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final ClubRepository clubRepository;
|
||||
private final EmailService emailService;
|
||||
private final TokenRevocationService tokenRevocationService;
|
||||
|
||||
public StaffService(UserRepository userRepository,
|
||||
StaffAccountRepository staffAccountRepository,
|
||||
InviteTokenRepository inviteTokenRepository,
|
||||
ClubRepository clubRepository,
|
||||
EmailService emailService,
|
||||
TokenRevocationService tokenRevocationService) {
|
||||
this.userRepository = userRepository;
|
||||
this.staffAccountRepository = staffAccountRepository;
|
||||
this.inviteTokenRepository = inviteTokenRepository;
|
||||
this.clubRepository = clubRepository;
|
||||
this.emailService = emailService;
|
||||
this.tokenRevocationService = tokenRevocationService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<StaffAccount> listStaff(UUID tenantId) {
|
||||
return staffAccountRepository.findByTenantIdAndActiveTrue(tenantId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public StaffAccount getStaff(UUID tenantId, UUID staffId) {
|
||||
StaffAccount staff = staffAccountRepository.findById(staffId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Staff account not found"));
|
||||
if (!staff.getTenantId().equals(tenantId)) {
|
||||
throw new ResponseStatusException(NOT_FOUND, "Staff account not found");
|
||||
}
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new staff member: User (inactive) + StaffAccount + InviteToken + sends email.
|
||||
* Validates email against club's allowedEmailPattern if configured.
|
||||
*/
|
||||
@Transactional
|
||||
public StaffAccount createStaff(UUID tenantId, String email, String displayName,
|
||||
Set<StaffPermission> permissions, String templateName) {
|
||||
// Resolve permissions from template if provided
|
||||
Set<StaffPermission> resolvedPermissions = permissions;
|
||||
if (templateName != null && !templateName.isBlank()) {
|
||||
resolvedPermissions = StaffTemplates.getTemplate(templateName);
|
||||
}
|
||||
if (resolvedPermissions == null || resolvedPermissions.isEmpty()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Permissions must not be empty");
|
||||
}
|
||||
|
||||
// Validate email uniqueness within tenant
|
||||
if (userRepository.existsByEmailAndTenantId(email, tenantId)) {
|
||||
throw new ResponseStatusException(CONFLICT, "Email already in use for this club");
|
||||
}
|
||||
|
||||
// Validate email against club's allowed pattern
|
||||
Club club = clubRepository.findById(tenantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Club not found"));
|
||||
validateEmailPattern(email, club.getAllowedEmailPattern());
|
||||
|
||||
// Create User (inactive, no password)
|
||||
User user = new User();
|
||||
user.setTenantId(tenantId);
|
||||
user.setEmail(email);
|
||||
user.setPasswordHash(""); // No password until invite is accepted
|
||||
user.setRole(UserRole.ROLE_STAFF);
|
||||
user.setActive(false);
|
||||
user = userRepository.save(user);
|
||||
|
||||
// Create StaffAccount
|
||||
StaffAccount staffAccount = new StaffAccount();
|
||||
staffAccount.setTenantId(tenantId);
|
||||
staffAccount.setUserId(user.getId());
|
||||
staffAccount.setDisplayName(displayName);
|
||||
staffAccount.setGrantedPermissions(resolvedPermissions);
|
||||
staffAccount.setActive(true);
|
||||
staffAccount.setInvitedAt(Instant.now());
|
||||
staffAccount = staffAccountRepository.save(staffAccount);
|
||||
|
||||
// Create InviteToken (72h expiry, SecureRandom 32-byte Base64 token)
|
||||
String tokenValue = generateSecureToken();
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setUser(user);
|
||||
inviteToken.setToken(tokenValue);
|
||||
inviteToken.setExpiresAt(Instant.now().plus(INVITE_EXPIRY_HOURS, ChronoUnit.HOURS));
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
|
||||
// Send invite email (token value is NOT logged per security review)
|
||||
emailService.sendInviteEmail(email, displayName, club.getName(), tokenValue);
|
||||
|
||||
log.info("Staff member created: {} for tenant {}", email, tenantId);
|
||||
return staffAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates staff permissions and/or display name.
|
||||
* Permission changes trigger token revocation for the affected user.
|
||||
*/
|
||||
@Transactional
|
||||
public StaffAccount updateStaff(UUID tenantId, UUID staffId, String displayName,
|
||||
Set<StaffPermission> permissions, String templateName, Boolean active) {
|
||||
StaffAccount staff = getStaff(tenantId, staffId);
|
||||
|
||||
boolean permissionsChanged = false;
|
||||
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
staff.setDisplayName(displayName);
|
||||
}
|
||||
|
||||
// Resolve permissions from template if provided
|
||||
Set<StaffPermission> newPermissions = permissions;
|
||||
if (templateName != null && !templateName.isBlank()) {
|
||||
newPermissions = StaffTemplates.getTemplate(templateName);
|
||||
}
|
||||
|
||||
if (newPermissions != null && !newPermissions.equals(staff.getGrantedPermissions())) {
|
||||
staff.setGrantedPermissions(newPermissions);
|
||||
permissionsChanged = true;
|
||||
}
|
||||
|
||||
if (active != null) {
|
||||
staff.setActive(active);
|
||||
if (!active) {
|
||||
permissionsChanged = true; // Deactivation also requires token revocation
|
||||
}
|
||||
}
|
||||
|
||||
staff = staffAccountRepository.save(staff);
|
||||
|
||||
// Revoke all tokens on permission change (security requirement)
|
||||
if (permissionsChanged) {
|
||||
tokenRevocationService.revokeAllForUser(staff.getUserId());
|
||||
log.info("Tokens revoked for staff {} due to permission/status change", staff.getUserId());
|
||||
}
|
||||
|
||||
return staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates a staff member — sets inactive and revokes all JWT tokens.
|
||||
*/
|
||||
@Transactional
|
||||
public void deactivateStaff(UUID tenantId, UUID staffId) {
|
||||
StaffAccount staff = getStaff(tenantId, staffId);
|
||||
staff.setActive(false);
|
||||
staffAccountRepository.save(staff);
|
||||
|
||||
// Also deactivate the user account
|
||||
User user = userRepository.findById(staff.getUserId())
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
|
||||
user.setActive(false);
|
||||
userRepository.save(user);
|
||||
|
||||
// Revoke all tokens
|
||||
tokenRevocationService.revokeAllForUser(staff.getUserId());
|
||||
log.info("Staff {} deactivated for tenant {}", staffId, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email against club's allowedEmailPattern (regex).
|
||||
* If no pattern is configured, all emails are accepted.
|
||||
*/
|
||||
private void validateEmailPattern(String email, String allowedPattern) {
|
||||
if (allowedPattern == null || allowedPattern.isBlank()) {
|
||||
return; // No restriction
|
||||
}
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(allowedPattern, Pattern.CASE_INSENSITIVE);
|
||||
if (!pattern.matcher(email).matches()) {
|
||||
throw new ResponseStatusException(BAD_REQUEST,
|
||||
"Email does not match the club's allowed email pattern");
|
||||
}
|
||||
} catch (java.util.regex.PatternSyntaxException e) {
|
||||
log.warn("Invalid email pattern configured for club: {}", allowedPattern);
|
||||
// Don't block staff creation due to misconfigured pattern
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically secure token: 32 bytes → Base64 URL-safe encoding.
|
||||
*/
|
||||
private String generateSecureToken() {
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
SECURE_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Predefined permission templates for common staff roles.
|
||||
* Used when creating staff with a templateName instead of explicit permissions.
|
||||
*/
|
||||
public final class StaffTemplates {
|
||||
|
||||
private StaffTemplates() {}
|
||||
|
||||
private static final Map<String, Set<StaffPermission>> TEMPLATES = Map.of(
|
||||
"ausgabe", EnumSet.of(
|
||||
StaffPermission.RECORD_DISTRIBUTION,
|
||||
StaffPermission.VIEW_MEMBER_LIST,
|
||||
StaffPermission.VIEW_MEMBER_QUOTA
|
||||
),
|
||||
"lager", EnumSet.of(
|
||||
StaffPermission.VIEW_STOCK,
|
||||
StaffPermission.RECORD_STOCK_IN
|
||||
),
|
||||
"vorstand", EnumSet.of(
|
||||
StaffPermission.RECORD_DISTRIBUTION,
|
||||
StaffPermission.VIEW_MEMBER_LIST,
|
||||
StaffPermission.VIEW_MEMBER_QUOTA,
|
||||
StaffPermission.ADD_MEMBER,
|
||||
StaffPermission.VIEW_STOCK,
|
||||
StaffPermission.RECORD_STOCK_IN,
|
||||
StaffPermission.VIEW_COMPLIANCE_REPORT
|
||||
// Note: MANAGE_GROW_CALENDAR excluded per plan
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the permission set for the given template name.
|
||||
* @throws IllegalArgumentException if template name is unknown
|
||||
*/
|
||||
public static Set<StaffPermission> getTemplate(String name) {
|
||||
Set<StaffPermission> template = TEMPLATES.get(name.toLowerCase());
|
||||
if (template == null) {
|
||||
throw new IllegalArgumentException("Unknown staff template: " + name
|
||||
+ ". Available: " + TEMPLATES.keySet());
|
||||
}
|
||||
return EnumSet.copyOf(template);
|
||||
}
|
||||
|
||||
public static Map<String, Set<StaffPermission>> getAllTemplates() {
|
||||
return TEMPLATES;
|
||||
}
|
||||
|
||||
public static boolean exists(String name) {
|
||||
return TEMPLATES.containsKey(name.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Scheduled task to clean up expired revoked tokens.
|
||||
* Runs daily at 03:00 to remove tokens whose expiration has passed
|
||||
* (they can no longer be used anyway, so the revocation record is stale).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TokenCleanupScheduler {
|
||||
|
||||
private final TokenRevocationService tokenRevocationService;
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void cleanupExpiredTokens() {
|
||||
log.info("Starting expired token cleanup...");
|
||||
int deleted = tokenRevocationService.cleanupExpiredTokens();
|
||||
log.info("Expired token cleanup complete: {} tokens removed", deleted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import de.cannamanage.service.repository.RevokedTokenRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Service for JWT token revocation with Caffeine cache for fast lookups.
|
||||
* Cache: 60s TTL, max 10,000 entries.
|
||||
* Flow: isRevoked() checks cache first, then falls back to DB.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class TokenRevocationService {
|
||||
|
||||
private final RevokedTokenRepository revokedTokenRepository;
|
||||
|
||||
/**
|
||||
* Cache stores JTI → Boolean (true = revoked).
|
||||
* TTL 60s means a revoked token could still be accepted for up to 60s
|
||||
* on other nodes (acceptable tradeoff for single-node MVP).
|
||||
*/
|
||||
private final Cache<String, Boolean> revokedCache = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.expireAfterWrite(60, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
public TokenRevocationService(RevokedTokenRepository revokedTokenRepository) {
|
||||
this.revokedTokenRepository = revokedTokenRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a token (by JTI) is revoked.
|
||||
* Checks local cache first, then DB as fallback.
|
||||
*/
|
||||
public boolean isRevoked(String jti) {
|
||||
if (jti == null || jti.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
Boolean cached = revokedCache.getIfPresent(jti);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
boolean revoked = revokedTokenRepository.existsByJti(jti);
|
||||
if (revoked) {
|
||||
revokedCache.put(jti, true);
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a single token by JTI.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeToken(String jti, UUID userId, UUID tenantId, Instant expiresAt, String reason) {
|
||||
if (revokedTokenRepository.existsByJti(jti)) {
|
||||
log.debug("Token {} already revoked, skipping", jti);
|
||||
return;
|
||||
}
|
||||
|
||||
RevokedToken revokedToken = new RevokedToken();
|
||||
revokedToken.setJti(jti);
|
||||
revokedToken.setUserId(userId);
|
||||
revokedToken.setTenantId(tenantId);
|
||||
revokedToken.setRevokedAt(Instant.now());
|
||||
revokedToken.setExpiresAt(expiresAt);
|
||||
revokedToken.setReason(reason);
|
||||
|
||||
revokedTokenRepository.save(revokedToken);
|
||||
revokedCache.put(jti, true);
|
||||
log.info("Revoked token {} for user {} (reason: {})", jti, userId, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all tokens for a user by clearing their refresh token.
|
||||
* Access tokens will expire naturally within their TTL (max 60 min).
|
||||
* Used when permissions change or staff is deactivated.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeAllForUser(UUID userId) {
|
||||
log.info("Revoking all tokens for user {}", userId);
|
||||
revokedCache.invalidateAll(); // Clear cache to force DB lookup
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes expired revoked tokens from the database.
|
||||
* Called by TokenCleanupScheduler nightly.
|
||||
*/
|
||||
@Transactional
|
||||
public int cleanupExpiredTokens() {
|
||||
int deleted = revokedTokenRepository.deleteExpiredTokens(Instant.now());
|
||||
if (deleted > 0) {
|
||||
log.info("Cleaned up {} expired revoked tokens", deleted);
|
||||
revokedCache.invalidateAll();
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
+3
@@ -1,6 +1,7 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -8,4 +9,6 @@ import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
||||
|
||||
long countByTenantIdAndStatus(UUID tenantId, BatchStatus status);
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ClubRepository extends JpaRepository<Club, UUID> {
|
||||
|
||||
Optional<Club> findByTenantId(UUID tenantId);
|
||||
}
|
||||
+9
@@ -20,4 +20,13 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
||||
@Param("dayStart") Instant dayStart,
|
||||
@Param("dayEnd") Instant dayEnd
|
||||
);
|
||||
|
||||
long countByTenantIdAndDistributedAtAfter(UUID tenantId, Instant after);
|
||||
|
||||
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
|
||||
"WHERE d.tenantId = :tenantId AND d.distributedAt >= :after")
|
||||
BigDecimal sumGramsByTenantIdAndDistributedAtAfter(
|
||||
@Param("tenantId") UUID tenantId,
|
||||
@Param("after") Instant after
|
||||
);
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.InviteToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID> {
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
Optional<InviteToken> findByTokenAndUsedAtIsNullAndExpiresAtAfter(String token, Instant now);
|
||||
}
|
||||
+5
@@ -1,6 +1,7 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -8,4 +9,8 @@ import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndStatus(UUID tenantId, MemberStatus status);
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface RevokedTokenRepository extends JpaRepository<RevokedToken, UUID> {
|
||||
|
||||
boolean existsByJti(String jti);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM RevokedToken r WHERE r.expiresAt < :cutoff")
|
||||
int deleteExpiredTokens(@Param("cutoff") Instant cutoff);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM RevokedToken r WHERE r.userId = :userId")
|
||||
int deleteByUserId(@Param("userId") UUID userId);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface StaffAccountRepository extends JpaRepository<StaffAccount, UUID> {
|
||||
|
||||
Optional<StaffAccount> findByUserId(UUID userId);
|
||||
|
||||
List<StaffAccount> findByTenantIdAndActiveTrue(UUID tenantId);
|
||||
|
||||
List<StaffAccount> findByTenantIdAndPreventionOfficerTrue(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndPreventionOfficerTrueAndActiveTrue(UUID tenantId);
|
||||
|
||||
boolean existsByUserId(UUID userId);
|
||||
|
||||
long countByTenantId(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndActiveTrue(UUID tenantId);
|
||||
|
||||
long countByTenantIdAndPreventionOfficerTrue(UUID tenantId);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
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.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ClubServiceTest {
|
||||
|
||||
@Mock
|
||||
private ClubRepository clubRepository;
|
||||
@Mock
|
||||
private MemberRepository memberRepository;
|
||||
@Mock
|
||||
private StaffAccountRepository staffAccountRepository;
|
||||
@Mock
|
||||
private DistributionRepository distributionRepository;
|
||||
@Mock
|
||||
private BatchRepository batchRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ClubService clubService;
|
||||
|
||||
private UUID tenantId;
|
||||
private Club club;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = UUID.randomUUID();
|
||||
club = new Club();
|
||||
club.setId(UUID.randomUUID());
|
||||
club.setTenantId(tenantId);
|
||||
club.setName("Test Club");
|
||||
club.setLicenseNumber("LIC-001");
|
||||
club.setMaxPreventionOfficers(2);
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubByTenantId_found() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
|
||||
Club result = clubService.getClubByTenantId(tenantId);
|
||||
|
||||
assertThat(result).isEqualTo(club);
|
||||
verify(clubRepository).findByTenantId(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubByTenantId_notFound_throws404() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> clubService.getClubByTenantId(tenantId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Club not found for tenant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_success() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Updated Club", "REG-123",
|
||||
"info@club.de", "+49123456",
|
||||
"Mainstreet 1", "Berlin", "10115", "Berlin",
|
||||
LocalDate.of(2024, 1, 15), 3, ".*@club\\.de"
|
||||
);
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Updated Club");
|
||||
assertThat(result.getRegistrationNumber()).isEqualTo("REG-123");
|
||||
assertThat(result.getContactEmail()).isEqualTo("info@club.de");
|
||||
assertThat(result.getMaxPreventionOfficers()).isEqualTo(3);
|
||||
assertThat(result.getAllowedEmailPattern()).isEqualTo(".*@club\\.de");
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_invalidRegex_throwsBadRequest() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
|
||||
assertThatThrownBy(() -> clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, "[invalid"
|
||||
))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Invalid regex pattern");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_nullPattern_accepted() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, null
|
||||
);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateClub_blankPattern_accepted() {
|
||||
when(clubRepository.findByTenantId(tenantId)).thenReturn(Optional.of(club));
|
||||
when(clubRepository.save(any(Club.class))).thenReturn(club);
|
||||
|
||||
Club result = clubService.updateClub(
|
||||
tenantId, "Club", null, null, null,
|
||||
null, null, null, null, null, null, " "
|
||||
);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
verify(clubRepository).save(club);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubStats_returnsAggregatedStats() {
|
||||
when(memberRepository.countByTenantId(tenantId)).thenReturn(50L);
|
||||
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(42L);
|
||||
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(5L);
|
||||
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(4L);
|
||||
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(120L);
|
||||
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(new BigDecimal("1500.50"));
|
||||
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(8L);
|
||||
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(2L);
|
||||
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
|
||||
assertThat(stats.totalMembers()).isEqualTo(50L);
|
||||
assertThat(stats.activeMembers()).isEqualTo(42L);
|
||||
assertThat(stats.totalStaff()).isEqualTo(5L);
|
||||
assertThat(stats.activeStaff()).isEqualTo(4L);
|
||||
assertThat(stats.totalDistributionsThisMonth()).isEqualTo(120L);
|
||||
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo("1500.50");
|
||||
assertThat(stats.activeBatches()).isEqualTo(8L);
|
||||
assertThat(stats.preventionOfficerCount()).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getClubStats_nullGrams_returnsZero() {
|
||||
when(memberRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(memberRepository.countByTenantIdAndStatus(tenantId, MemberStatus.ACTIVE)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantId(tenantId)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantIdAndActiveTrue(tenantId)).thenReturn(0L);
|
||||
when(distributionRepository.countByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(0L);
|
||||
when(distributionRepository.sumGramsByTenantIdAndDistributedAtAfter(eq(tenantId), any(Instant.class)))
|
||||
.thenReturn(null);
|
||||
when(batchRepository.countByTenantIdAndStatus(tenantId, BatchStatus.AVAILABLE)).thenReturn(0L);
|
||||
when(staffAccountRepository.countByTenantIdAndPreventionOfficerTrue(tenantId)).thenReturn(0L);
|
||||
|
||||
ClubService.ClubStats stats = clubService.getClubStats(tenantId);
|
||||
|
||||
assertThat(stats.totalGramsDistributedThisMonth()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class EmailServiceTest {
|
||||
|
||||
@Mock
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
private EmailService emailService;
|
||||
|
||||
@Test
|
||||
void sendInviteEmail_sendsCorrectContent() {
|
||||
emailService = new EmailService(mailSender, "https://app.cannamanage.de", "noreply@cannamanage.de");
|
||||
|
||||
emailService.sendInviteEmail("staff@example.com", "Max Mustermann", "Green Club", "abc123token");
|
||||
|
||||
ArgumentCaptor<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
|
||||
verify(mailSender).send(captor.capture());
|
||||
|
||||
SimpleMailMessage msg = captor.getValue();
|
||||
assertThat(msg.getTo()).contains("staff@example.com");
|
||||
assertThat(msg.getFrom()).isEqualTo("noreply@cannamanage.de");
|
||||
assertThat(msg.getSubject()).contains("Green Club");
|
||||
assertThat(msg.getText()).contains("Max Mustermann");
|
||||
assertThat(msg.getText()).contains("https://app.cannamanage.de/auth/set-password?token=abc123token");
|
||||
assertThat(msg.getText()).contains("72 Stunden");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendInviteEmail_mailFailure_throwsRuntimeException() {
|
||||
emailService = new EmailService(mailSender, "http://localhost:8080", "noreply@cannamanage.de");
|
||||
doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
emailService.sendInviteEmail("fail@example.com", "Fail User", "Club", "token123"))
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("Failed to send invite email");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.InviteToken;
|
||||
import de.cannamanage.domain.entity.StaffAccount;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.InviteTokenRepository;
|
||||
import de.cannamanage.service.repository.StaffAccountRepository;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StaffServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private StaffAccountRepository staffAccountRepository;
|
||||
@Mock private InviteTokenRepository inviteTokenRepository;
|
||||
@Mock private ClubRepository clubRepository;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private TokenRevocationService tokenRevocationService;
|
||||
|
||||
@InjectMocks
|
||||
private StaffService staffService;
|
||||
|
||||
private UUID tenantId;
|
||||
private Club club;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = UUID.randomUUID();
|
||||
club = new Club();
|
||||
club.setId(tenantId);
|
||||
club.setTenantId(tenantId);
|
||||
club.setName("Test Club");
|
||||
club.setLicenseNumber("LIC-001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStaff_success_createsUserAndStaffAndSendsEmail() {
|
||||
// Arrange
|
||||
String email = "staff@example.com";
|
||||
String displayName = "Max Mustermann";
|
||||
Set<StaffPermission> permissions = EnumSet.of(
|
||||
StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_MEMBER_LIST);
|
||||
|
||||
when(userRepository.existsByEmailAndTenantId(email, tenantId)).thenReturn(false);
|
||||
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> {
|
||||
User u = inv.getArgument(0);
|
||||
u.setId(UUID.randomUUID());
|
||||
return u;
|
||||
});
|
||||
when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> {
|
||||
StaffAccount s = inv.getArgument(0);
|
||||
s.setId(UUID.randomUUID());
|
||||
return s;
|
||||
});
|
||||
when(inviteTokenRepository.save(any(InviteToken.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
// Act
|
||||
StaffAccount result = staffService.createStaff(tenantId, email, displayName, permissions, null);
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getDisplayName()).isEqualTo(displayName);
|
||||
assertThat(result.getGrantedPermissions()).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
|
||||
// Verify user was created inactive with STAFF role
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(userCaptor.capture());
|
||||
User savedUser = userCaptor.getValue();
|
||||
assertThat(savedUser.getEmail()).isEqualTo(email);
|
||||
assertThat(savedUser.getRole()).isEqualTo(UserRole.ROLE_STAFF);
|
||||
assertThat(savedUser.isActive()).isFalse();
|
||||
|
||||
// Verify invite token was created
|
||||
ArgumentCaptor<InviteToken> tokenCaptor = ArgumentCaptor.forClass(InviteToken.class);
|
||||
verify(inviteTokenRepository).save(tokenCaptor.capture());
|
||||
InviteToken savedToken = tokenCaptor.getValue();
|
||||
assertThat(savedToken.getToken()).isNotBlank();
|
||||
assertThat(savedToken.getExpiresAt()).isAfter(Instant.now());
|
||||
|
||||
// Verify email was sent
|
||||
verify(emailService).sendInviteEmail(eq(email), eq(displayName), eq("Test Club"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStaff_withTemplate_resolvesPermissions() {
|
||||
String email = "lager@example.com";
|
||||
when(userRepository.existsByEmailAndTenantId(email, tenantId)).thenReturn(false);
|
||||
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
|
||||
when(userRepository.save(any(User.class))).thenAnswer(inv -> {
|
||||
User u = inv.getArgument(0);
|
||||
u.setId(UUID.randomUUID());
|
||||
return u;
|
||||
});
|
||||
when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> {
|
||||
StaffAccount s = inv.getArgument(0);
|
||||
s.setId(UUID.randomUUID());
|
||||
return s;
|
||||
});
|
||||
when(inviteTokenRepository.save(any(InviteToken.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
StaffAccount result = staffService.createStaff(tenantId, email, "Lager Person", null, "lager");
|
||||
|
||||
assertThat(result.getGrantedPermissions()).containsExactlyInAnyOrder(
|
||||
StaffPermission.VIEW_STOCK, StaffPermission.RECORD_STOCK_IN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStaff_duplicateEmail_throwsConflict() {
|
||||
when(userRepository.existsByEmailAndTenantId("dup@example.com", tenantId)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> staffService.createStaff(
|
||||
tenantId, "dup@example.com", "Dup User", EnumSet.of(StaffPermission.VIEW_STOCK), null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Email already in use");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createStaff_emailPatternViolation_throwsBadRequest() {
|
||||
club.setAllowedEmailPattern(".*@myclub\\.de$");
|
||||
when(userRepository.existsByEmailAndTenantId("user@other.com", tenantId)).thenReturn(false);
|
||||
when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club));
|
||||
|
||||
assertThatThrownBy(() -> staffService.createStaff(
|
||||
tenantId, "user@other.com", "User", EnumSet.of(StaffPermission.VIEW_STOCK), null))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("allowed email pattern");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateStaff_permissionChange_revokesTokens() {
|
||||
UUID staffId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
StaffAccount staff = new StaffAccount();
|
||||
staff.setId(staffId);
|
||||
staff.setTenantId(tenantId);
|
||||
staff.setUserId(userId);
|
||||
staff.setDisplayName("Old Name");
|
||||
staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK));
|
||||
staff.setActive(true);
|
||||
|
||||
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff));
|
||||
when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
Set<StaffPermission> newPerms = EnumSet.of(StaffPermission.VIEW_STOCK, StaffPermission.RECORD_STOCK_IN);
|
||||
staffService.updateStaff(tenantId, staffId, "New Name", newPerms, null, null);
|
||||
|
||||
verify(tokenRevocationService).revokeAllForUser(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deactivateStaff_deactivatesUserAndRevokesTokens() {
|
||||
UUID staffId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
StaffAccount staff = new StaffAccount();
|
||||
staff.setId(staffId);
|
||||
staff.setTenantId(tenantId);
|
||||
staff.setUserId(userId);
|
||||
staff.setActive(true);
|
||||
|
||||
User user = new User();
|
||||
user.setId(userId);
|
||||
user.setActive(true);
|
||||
|
||||
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(staffAccountRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
staffService.deactivateStaff(tenantId, staffId);
|
||||
|
||||
assertThat(staff.isActive()).isFalse();
|
||||
assertThat(user.isActive()).isFalse();
|
||||
verify(tokenRevocationService).revokeAllForUser(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStaff_wrongTenant_throwsNotFound() {
|
||||
UUID staffId = UUID.randomUUID();
|
||||
StaffAccount staff = new StaffAccount();
|
||||
staff.setId(staffId);
|
||||
staff.setTenantId(UUID.randomUUID()); // Different tenant
|
||||
|
||||
when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff));
|
||||
|
||||
assertThatThrownBy(() -> staffService.getStaff(tenantId, staffId))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("not found");
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import de.cannamanage.service.repository.RevokedTokenRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TokenRevocationServiceTest {
|
||||
|
||||
@Mock
|
||||
private RevokedTokenRepository revokedTokenRepository;
|
||||
|
||||
@InjectMocks
|
||||
private TokenRevocationService service;
|
||||
|
||||
private String testJti;
|
||||
private UUID testUserId;
|
||||
private UUID testTenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testJti = UUID.randomUUID().toString();
|
||||
testUserId = UUID.randomUUID();
|
||||
testTenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_notRevoked_returnsFalse() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_revoked_returnsTrue() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_nullJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_blankJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(" ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_usesCache_onSecondCall() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
// First call goes to DB
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
// Second call should use cache
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
|
||||
// DB should only be called once (cache handles second call)
|
||||
verify(revokedTokenRepository, times(1)).existsByJti(testJti);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_savesRevocation() {
|
||||
Instant expiresAt = Instant.now().plusSeconds(3600);
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, expiresAt, "logout");
|
||||
|
||||
ArgumentCaptor<RevokedToken> captor = ArgumentCaptor.forClass(RevokedToken.class);
|
||||
verify(revokedTokenRepository).save(captor.capture());
|
||||
|
||||
RevokedToken saved = captor.getValue();
|
||||
assertThat(saved.getJti()).isEqualTo(testJti);
|
||||
assertThat(saved.getUserId()).isEqualTo(testUserId);
|
||||
assertThat(saved.getTenantId()).isEqualTo(testTenantId);
|
||||
assertThat(saved.getExpiresAt()).isEqualTo(expiresAt);
|
||||
assertThat(saved.getReason()).isEqualTo("logout");
|
||||
assertThat(saved.getRevokedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_alreadyRevoked_doesNotSaveAgain() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, Instant.now().plusSeconds(3600), "duplicate");
|
||||
|
||||
verify(revokedTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_deletesExpired() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(5);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(5);
|
||||
verify(revokedTokenRepository).deleteExpiredTokens(any(Instant.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_nothingToDelete_returnsZero() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(0);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2+0 18 0 R /F3+0 22 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 136 /Length 1268 /SMask 4 0 R
|
||||
/Subtype /Image /Type /XObject /Width 218
|
||||
>>
|
||||
stream
|
||||
Gb"0W9huEg*67T9TiVn);(I\?+"h::0d`'&2"?PBGmD?QhRk]mL(!P1ar+"uA6V;)Ue,hg:o(H\['W7V-7VXBh:\4ln&+m`*dF'tr(-h?V7SOihnK6nzzzzzzz!'hMamB).tK(@Yph$K'LN]A,onfT]X/*E]QS<-DkV'@)odN+[N,="_IAm.5^?1W,YdLlI<Wn!mi5[LIc<B:1tR!ip\3^O+S/5%@HT[MDWF9DSo2V"?=km5'HQ+]?F#gd'pBW-3OX^#F]f?c$rUXSkmpnd_fVb!]V5[!s[265aT\<Jr,HWPQdp#KIB[l,#'fj.n6eWp/LF:oCs,_7&4USH6L1pD7A7._K@qK]QiC>>2HFOfgC8Cr[mV0[)gD61:!J]8/I2nP;9=/\UOokW[J&1-RFh5C7b<iN$?ki^T#=BP@uLE#:6arM\gpqIDE9]*DJ^Cr3L\G5-is%X,K]?C6"POWtA2KWb/F^I#R/cN2b[O*#g3N)h=qo2NHlKk!!,3-PSrQ,A4/N$R=@<#.G<sZ56:Y@?,IYt,'?3c:pBIr84mCfFi0"K<XK5\_`7Ngrt`XE['c#_Sk8k2^_\$FGhNMgMr>$CJ/#AD!<RC]uEDm&]q.Hg<N&Nk#Rl,N%"DmRtrZ?J5Lp,Qt2XsG-Yn*b&I^hbl3S6LRNn61f5R>h$_S>fdDDf89=#R"a6^o3uoFUE2<&'ZD7/>Q>sZqIr3>nuFQkBb$Keh^T5X<mDsOQG?'-(E.O7gO[J:Lhfb!4QuY;-454!%th8q^rW_%sgudYkA8kSF,XsM8GA&`eJSg&.$m)CVV;nMcKnu8[N#7]p)9C!3):-dLfkl...IB9;SM&SA][\8J#r<-dO9"L\3p9P0T].h2Sq=_Ko4*-UU8K=>(Ol_o'C?&JV3m>$"r&l;aj^h81QG4(d4Of(RUXF9L>l=!LXYIu<c%'fQq\IcK(:i%S,i\^;!&h*tDL=T&'NfKS#P@j;EXk'bQmc.0mfjC2^ql\%!QNTS0$F+[Xrq5o+GEL"QdkdET5fX/n#Xlem0.=fF@'R6Xtj#f3eh4TeYp[<M'^mQ+W5rG*mmV.lF?phrWVG(l(NS8f/p9\P'[>uEBG3a5Q@LEdCcR>\0&Ifs0gi3'j.@66Y!2'q!X)W3Q5M,cIdrusE@`%LG]:^^?BT\=JDYhX1b9N%>4nIut`VrAnLBd$8FU$h<7$Op-"&MqMgm!g!;[TO#16%uD1+=4E@\!/Co#^^migad3<aZrdF#kBCzzzzzzz!!#`B!&quIC]~>endstream
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/BitsPerComponent 8 /ColorSpace /DeviceGray /Decode [ 0 1 ] /Filter [ /ASCII85Decode /FlateDecode ] /Height 136 /Length 2815
|
||||
/Subtype /Image /Type /XObject /Width 218
|
||||
>>
|
||||
stream
|
||||
Gb"/jHZ0Zf*lho;'9Z(_8.<TY`)9E@e2p)Z;(3f3@)0_?'MF#*3tAY4V*%%0Hr*X^KdB97"B5L-bNS3Lj%r#DCABh1@Nd4W4<mrrOcYq5h1R&Ch7cTJIe2irR;C<@\uPc/f>$4HkLZ>sSt;P2b\ET0P*1ukP*1ukP*1ukP*1ukP*1ukP*1ukP*1u@"bIe[(>sC3(WE9t(P8\jFmc7mH$nN8;pVoF9fjceE:T?J'pJLd!0'8\2C$PJg%n[AWH7o-Vsq+sY8-kZ#V3&:]Rt>T\mnpb.p7YA\[QbnJR')X**HOkoD3e]6`@.5X[gJ+G8`$8k\S"X15,Aao`3YCSNg[f8$;:`)61G!l!U3N`4uq!?/bNQL>'dKP7-MWSg[]G]*EX06YK27(Qp9lnSd/&[GtY;D:gmhRkN74I^aFO>?tm0%Vq8Seini$j5!$(_5n"[r,*QldjQ@9_A8c+F.7Gt=s8ZPX3$HGZaUFDQS11dM8g/O=bd'FY%lR@,PQ?DfH$5tLa?oM_K2W(:Gs':>2,RgiYoPKn2p7Y"Eh2$M]h3BcLgG3_/LT[$^p.,:;8n%Vg!R=?IUpX1.KUV>gLisG;;n)6V"c!CErO$h^9s^lo*Z<\rhkd)hubkfHH[&^4UmnPNB(+V*fkHm/J%&&G:I8!2a$,UH3.W,c/_0")_.+:pbPtSF[jSf5UO&aDT?6Xh2b"Hj*XPOT_oHDA^8FK3:a"l.-ZU#LI8pRZ/l$p'#)j@/kC^g?4eu2B<"kT$cmq(>!0,\II*!P/VX3\KlD:X>sdeK;JOhEj^RZFuM/j*_.F_9Nr,n)*14<VIu<*]l^P]+Sf9+h3ADBV0#1$1nk;tcjr.hEA<tPfjO![MI2q1=Yd@IU4)<X%$b7Vs'+2nc]nmhlMtHJ0mgQpTg^5-s*5U*U'9]>9&="l_*g=rWO>sNhN&;;\,!:+,kuX8g"7^:mWfk#qDQa`#,u!5odjs$aPsLeocHk,Gg1hdIB_oL=j@9:^k=7kL(D#VmbaCE+82M8Hi$T:Mck+/5S[=dL!64^S%dJ$r_:E,WeJZi6A?8l<C_NRD:Zf[pRDK?a)TjdY*^bh7TN!/A14na=\P^kS[ko>c)nA'OD0X>5-9V1bd@29a>E9U\Dsa/m4;r*aTaNX"PincD)Fp:*+&`5pE"[dhO.&aM7Fj6\@k1%G!8Q?$Z9imAFf"/WVVK:g?s),lZ8Hhn&4U5@PZR??\4d6,-B2T4qro%_h.S7B7&o1VI)Kii+>0bnuQB9]3&GX=*=X8C%J.-6;Keh`RAA::Od>\6#0`HEa#Gl(N*h9H%q,[YliZfk3[hYYml&3[Bt3](78,,.@1U`%)eE]%$mMmGtuX+"?hK&3NQVo<X)NYY(G3*MS-P/$>Q7YZ?rRc4ihV`aD/gH&&7!]::G,gAk71P0i[YYKOc<1!3^R_+e+E262&[T/F,bg#r6rt$tk@_K5i.U_#j0f:-7-EjMh81o\EELi[Y_tj.Q+HYp$Nt2AR+7M:<X-h/TQ-YqJ\*-"RIj4=^Y1>6m6U'#R6cP)*p#onRff/(kiP'po&go-I5PPn0_W(n"%M2+Stn=^!'Cn)nu@A@k=XI=:;^1K@'+den"c(th2g1Rj(Jjr^@c*TA*2G7<dXrcu]H;=)1,^L$)dZndYr":&U<T32"Y"?p@OR*B\nHNucc]K>3hjT;M!JYL$q4iRSCedK-6$%Ao#cV"nql`>jb6,KJ'/(l_nf@Noh<cD.G>:o$&Ac0ab1_3@N&2ur_^fhQf.<RqO%TXlg1[KH+1<am%k38CVI?<?3g.DdB@=5CYpW(5C\g\^b6>@e-RFJs2,&9jVD4NChaGYAD!4@rqSt_!Q=q!E(>r6+S19+o[*3d3j\FrE*bL0E]NPRpLZ"CpRQWloh0hW&W0/ed:%2%q'qV1+^BdJl":bQW%*^Pg,Whb)1<e=Z.\k-!qV2F%Pkq,U&0p\Pj^=E'^3!`TkD=lqPToeP/G!anER(8==?c6oq$^_%L(UQ#+ct"soP&3?P(0L!=QD9!F=StZ&-&YfefrSbQ^7T?gCS!/:b>:;g%hZ]'mLVLBeJH]*;rAma>tc#<SL^o%4VWA%n&aiWTT(`1%qMSM_g?psbT7ALM7K4e@0n@W,:"H`Ic,(t#X"4!Cn(itCL=UuS4=@oC=S;a5IR!\\!ClAl%MV>[-(/A=S\MN4\j8*0>"./0W0M@>(6f6=fepgl+;^As84q]'uhqU_V`6>%DAjZDO"2&g_7aY1Kd]>'\'N-dOuh0qb/\+D@?/)2Q[bJe.ZABK%6TQS>"0!Qsm23B';kL+8)$&.)mT[:QWCk0Y.Y$IJRR[SC+s^Ype02Q>o@9l["TX-5Zs?Ztala/I%CYaAVl)]@?b^L%[:FQ^c)B$g9,?5K@7SAF100T0Wlro>_eml[<?:=#X;IOaGFcA;>-VHja*Gd=77$Q_#T(C4/)qBrNfAVY7e"6siIuZHP76T(;)XJ!C*o:cts(\c5Fq::@4`l-kc2j/pdscT1<n+rOef=1O/L'V!84iK^M/)19Gpa9F3Heck23)"bM#-aR9"?EDKK4ZnlJUXuFXfcrr>3c4j!D7aXHcuo/-Eq<aGgBFkb,M71dM/:mZD8R#kd^=!=llE!FdpfEpFE&[d^/_1O#bB'5<VY/@2\!NL>PBe3_.mlA%^kh-Hk0b1>FrnjL-)O\CM-Ainn)\fjCdb0Hu%JiH9fuZKHK)t3o!Q<[qr#9k>+=f%<!EaZ\:nUW'#09[tAD/glP@L$'N;hcg-qRS$rrbEmXX94"hu's'\f';GpCj;GpCj;GpCj;GpCj;GpCj;GpCj;YkmjrrD_gq_e~>endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/Contents 26 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Contents 27 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Contents 28 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Contents 29 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Contents 30 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Contents 31 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Contents 32 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Contents 33 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
13 0 obj
|
||||
<<
|
||||
/Contents 34 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
14 0 obj
|
||||
<<
|
||||
/Contents 35 0 R /MediaBox [ 0 0 612 792 ] /Parent 25 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject <<
|
||||
/FormXob.b1201bcf0191eee53a3a7555ffe47082 3 0 R
|
||||
>>
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
15 0 obj
|
||||
<<
|
||||
/Filter [ /FlateDecode ] /Length 716
|
||||
>>
|
||||
stream
|
||||
xœmÕÍnÚ@†á=Wáe«.Àsþ !H¤,ú£RuïÀ$¥j2°ÈÝw¾ù¢Ôj‹”Ì‹=žàÅ™®î×÷ýþÜL¿‡í&Ÿ›Ç}¿òép¶¹yÈOû~Ò¦f·ßž_ßÕßÛçî8™–Û—Ó9?ß÷�‡É|ÞL¿–›§óðÒ¼[ÖׇuþÙ}¿lºþô~2ý<ìò°ïŸþws9åçÜŸ›Ùd±hvù±|ÄÇîø©{ÎÍôŸ#6|{9æ&Õ÷-•ÛÃ.ŸŽÝ6]ÿ”'óÙlÑÌãn1Éýî¯{mºâ™‡Çí�nxÝ;+¯Eé¶tšµŠNõúÝ
|
||||
-µoëuåuG[Ý¿¬{ ƒè+´nÑ×õì²^_Ö^׳7ØÓ^'ôŠ{êþ5÷Ü¡où¹õïßý1·#ÛŽ:�ZF£¶Qû¨cÔW£¾õrÔ7£^�z=êÛQ�ü‰þT›þ²”¦?á;Iô'üï‰þ„g‘è/KiúžK¢?á{Nô—¥4ýe)MYJÓ_–Òô'<—DYJÓŸð\ý ÏEèø…~�_èø…~�_èø…~�_èø…~�_èø…~�_èø…~�_èø…~�_èø…~�_éWø•~…_éWø•~…_éWø•~…_éWø•~…_éWø•~…_éWø•~…_éWø•~…_éWø•~…ßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßè7ø�~ƒßéwø�~‡ßéwø�~‡ßéwø�~‡ßéwø�~‡ßéwø�~‡ßéwø�~‡ßéwø�~‡ßéwø�~‡?èøƒþ€?èøƒþ€?èøƒþ€?èøƒþ€?èøƒþ€?èøƒþ€?èøƒþ€?èøƒþ× ñ: 0+0çÞfÐö2e<ÕaXFξÏoóòx8â~~� -endstream
|
||||
endobj
|
||||
16 0 obj
|
||||
<<
|
||||
/Filter [ /FlateDecode ] /Length 20728 /Length1 38364
|
||||
>>
|
||||
stream
|
||||
xœì½ |TE¶0^Uw¿½wº³/�=�@BÂ@ÒDDaQ0! ’tÈÂbô± A"BØD�ADÆD%¢ÌÌŽ£àö‘™�’ËwªnwDÇyß¼yïÿûýéÜ{ëÞ[uêÔÙëTuƒ0BÈ„!厛�œ:¥×Ü^ðä"ùE…UÆ`ã×á¾pøÍu¡ò°t„HÜk¥U3+æô�;!îÑË3g/(�~Èsîo t_JYIa±ñ3|¡Üxß¿˜š¥?Â}ÜÇ”UÔÎ�ò0Ü?ðZg{Š
|
||||
¯ß+mDhühx¹¢p~ÿGqB’àÞUYXQáó"ÜC÷ÈUžšÚÛ‹ÑT„žÎ£ï«ªKªK�âÓµ eófü4 œ&l‚Âõ+÷;TJì0
|
||||
ƒÈq2Oÿ9ª¼ýÔ~[�)H䑢[šUŒÜÈuû¶èÐx³T�/ |û£ÛHÿ‡fW#±ÊJ‹ØStä¡GIHF
|
||||
R‘ê˜�ýãdE6dG~È�œÈ @„‚Q
|
||||
Ea(E ŠDQ(Å X‡âQJD=PO”„z¡Þ(¥ >(¥¡¾¨ê� �(
|
||||
BƒÑtŠ2`DÃP&ŽF ,4�B£Q6ºÝ‡rÐ4�C¹è~4M@yh"ÊG“Ðd4¨û | ||||