diff --git a/cannamanage-api/pom.xml b/cannamanage-api/pom.xml index bfa4ee4..cdcdf3f 100644 --- a/cannamanage-api/pom.xml +++ b/cannamanage-api/pom.xml @@ -80,13 +80,24 @@ springdoc-openapi-starter-webmvc-ui 2.8.6 - + com.h2database h2 test - + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + org.springframework.boot spring-boot-starter-test @@ -97,6 +108,11 @@ spring-security-test test + + org.springframework.boot + spring-boot-testcontainers + test + diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java index 3371942..dc3c899 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java @@ -2,15 +2,19 @@ package de.cannamanage.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; /** * CannaManage Spring Boot application entry point. * Sprint 2: REST API + Spring Security + OpenAPI. * - * Entity scanning and repository detection handled automatically - * via scanBasePackages covering the full de.cannamanage hierarchy. + * Multi-module scanning: + * - scanBasePackages: component scanning (controllers, services) + * - EnableJpaRepositories: Spring Data JPA repository interfaces + * - Entity scanning configured via spring.jpa properties */ @SpringBootApplication(scanBasePackages = "de.cannamanage") +@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository") public class CannaManageApplication { public static void main(String[] args) { diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/config/OpenApiConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/config/OpenApiConfig.java new file mode 100644 index 0000000..7637045 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/config/OpenApiConfig.java @@ -0,0 +1,35 @@ +package de.cannamanage.api.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "CannaManage API", + version = "1.0.0", + description = "Cannabis Social Club Management — CanG Compliance Platform API", + contact = @Contact(name = "CannaManage", email = "info@cannamanage.de"), + license = @License(name = "Proprietary") + ), + servers = { + @Server(url = "/", description = "Current server") + }, + security = @SecurityRequirement(name = "bearer-jwt") +) +@SecurityScheme( + name = "bearer-jwt", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT access token — obtain via POST /api/v1/auth/login" +) +public class OpenApiConfig { +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java new file mode 100644 index 0000000..2bd4cc8 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java @@ -0,0 +1,38 @@ +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.service.AuthService; +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Login and token management") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + @Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/refresh") + @Operation(summary = "Refresh access token", description = "Exchanges a valid refresh token for new token pair") + public ResponseEntity refresh(@Valid @RequestBody RefreshRequest request) { + LoginResponse response = authService.refresh(request); + return ResponseEntity.ok(response); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java new file mode 100644 index 0000000..8ffe9eb --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/ComplianceController.java @@ -0,0 +1,42 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.compliance.QuotaResponse; +import de.cannamanage.service.ComplianceService; +import de.cannamanage.service.dto.QuotaStatus; +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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/compliance") +@RequiredArgsConstructor +@Tag(name = "Compliance", description = "CanG §19 compliance quota checks") +public class ComplianceController { + + private final ComplianceService complianceService; + + @GetMapping("/quota/{memberId}") + @Operation(summary = "Get member quota status", + description = "Returns current monthly remaining quota for a member per CanG §19") + public ResponseEntity getQuotaStatus(@PathVariable UUID memberId) { + QuotaStatus status = complianceService.getQuotaStatus(memberId); + + QuotaResponse response = new QuotaResponse( + status.totalAllowed(), + status.totalUsed(), + status.remaining(), + status.isUnder21(), + status.year(), + status.month() + ); + + return ResponseEntity.ok(response); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java new file mode 100644 index 0000000..bc060a3 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/DistributionController.java @@ -0,0 +1,75 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.distribution.CreateDistributionRequest; +import de.cannamanage.api.dto.distribution.DistributionResponse; +import de.cannamanage.domain.entity.Distribution; +import de.cannamanage.service.ComplianceService; +import de.cannamanage.service.repository.DistributionRepository; +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.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/distributions") +@RequiredArgsConstructor +@Tag(name = "Distributions", description = "Cannabis distribution recording (CanG §26)") +public class DistributionController { + + private final DistributionRepository distributionRepository; + private final ComplianceService complianceService; + + @GetMapping + @Operation(summary = "List all distributions", description = "Returns all distribution records for the current tenant") + public ResponseEntity> listDistributions() { + List distributions = distributionRepository.findAll().stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(distributions); + } + + @PostMapping + @Operation(summary = "Record a distribution", + description = "Records a cannabis distribution after compliance checks pass (CanG §19)") + public ResponseEntity createDistribution( + @Valid @RequestBody CreateDistributionRequest request, + Authentication authentication) { + + // Run compliance checks — throws QuotaExceededException if violated + complianceService.checkDistributionAllowed( + request.memberId(), request.batchId(), request.quantityGrams()); + + UUID recordedBy = (UUID) authentication.getPrincipal(); + + Distribution distribution = new Distribution(); + distribution.setMemberId(request.memberId()); + distribution.setBatchId(request.batchId()); + distribution.setQuantityGrams(request.quantityGrams()); + distribution.setDistributedAt(Instant.now()); + distribution.setRecordedBy(recordedBy); + distribution.setNotes(request.notes()); + + Distribution saved = distributionRepository.save(distribution); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved)); + } + + private DistributionResponse toResponse(Distribution d) { + return new DistributionResponse( + d.getId(), + d.getMemberId(), + d.getBatchId(), + d.getQuantityGrams(), + d.getDistributedAt(), + d.getRecordedBy(), + d.getNotes() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java new file mode 100644 index 0000000..c5d881e --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MemberController.java @@ -0,0 +1,105 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.member.CreateMemberRequest; +import de.cannamanage.api.dto.member.MemberResponse; +import de.cannamanage.api.dto.member.UpdateMemberRequest; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.repository.MemberRepository; +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.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.Period; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +@Tag(name = "Members", description = "Club member management") +public class MemberController { + + private final MemberRepository memberRepository; + + @GetMapping + @Operation(summary = "List all members", description = "Returns all members for the current tenant") + public ResponseEntity> listMembers() { + List members = memberRepository.findAll().stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(members); + } + + @GetMapping("/{id}") + @Operation(summary = "Get member by ID") + public ResponseEntity getMember(@PathVariable UUID id) { + Member member = memberRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + return ResponseEntity.ok(toResponse(member)); + } + + @PostMapping + @Operation(summary = "Create a new member") + public ResponseEntity createMember(@Valid @RequestBody CreateMemberRequest request) { + Member member = new Member(); + member.setFirstName(request.firstName()); + member.setLastName(request.lastName()); + member.setEmail(request.email()); + member.setDateOfBirth(request.dateOfBirth()); + member.setMembershipDate(request.membershipDate()); + member.setMembershipNumber(request.membershipNumber()); + member.setClubId(TenantContext.getCurrentTenant()); // club == tenant for MVP + member.setUnder21(isUnder21(request.dateOfBirth())); + + Member saved = memberRepository.save(member); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved)); + } + + @PutMapping("/{id}") + @Operation(summary = "Update a member") + public ResponseEntity updateMember(@PathVariable UUID id, + @Valid @RequestBody UpdateMemberRequest request) { + Member member = memberRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + if (request.firstName() != null) member.setFirstName(request.firstName()); + if (request.lastName() != null) member.setLastName(request.lastName()); + if (request.email() != null) member.setEmail(request.email()); + if (request.dateOfBirth() != null) { + member.setDateOfBirth(request.dateOfBirth()); + member.setUnder21(isUnder21(request.dateOfBirth())); + } + if (request.membershipNumber() != null) member.setMembershipNumber(request.membershipNumber()); + if (request.status() != null) member.setStatus(MemberStatus.valueOf(request.status())); + + Member saved = memberRepository.save(member); + return ResponseEntity.ok(toResponse(saved)); + } + + private boolean isUnder21(LocalDate dateOfBirth) { + return Period.between(dateOfBirth, LocalDate.now()).getYears() < 21; + } + + private MemberResponse toResponse(Member m) { + return new MemberResponse( + m.getId(), + m.getFirstName(), + m.getLastName(), + m.getEmail(), + m.getDateOfBirth(), + m.getMembershipDate(), + m.getMembershipNumber(), + m.getStatus(), + m.isUnder21(), + m.isPreventionOfficer() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java new file mode 100644 index 0000000..64d5d30 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StockController.java @@ -0,0 +1,70 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.stock.BatchResponse; +import de.cannamanage.api.dto.stock.CreateBatchRequest; +import de.cannamanage.domain.entity.Batch; +import de.cannamanage.domain.enums.BatchStatus; +import de.cannamanage.service.repository.BatchRepository; +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.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/stock/batches") +@RequiredArgsConstructor +@Tag(name = "Stock", description = "Batch and inventory management") +public class StockController { + + private final BatchRepository batchRepository; + + @GetMapping + @Operation(summary = "List all batches", description = "Returns all batches for the current tenant") + public ResponseEntity> listBatches() { + List batches = batchRepository.findAll().stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(batches); + } + + @GetMapping("/{id}") + @Operation(summary = "Get batch by ID") + public ResponseEntity getBatch(@PathVariable UUID id) { + Batch batch = batchRepository.findById(id) + .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( + HttpStatus.NOT_FOUND, "Batch not found")); + return ResponseEntity.ok(toResponse(batch)); + } + + @PostMapping + @Operation(summary = "Create a new batch", description = "Registers a new cannabis batch in inventory") + public ResponseEntity createBatch(@Valid @RequestBody CreateBatchRequest request) { + Batch batch = new Batch(); + batch.setStrainId(request.strainId()); + batch.setQuantityGrams(request.quantityGrams()); + batch.setHarvestDate(request.harvestDate()); + batch.setBatchCode(request.batchCode()); + batch.setStatus(BatchStatus.AVAILABLE); + + Batch saved = batchRepository.save(batch); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(saved)); + } + + private BatchResponse toResponse(Batch b) { + return new BatchResponse( + b.getId(), + b.getStrainId(), + b.getQuantityGrams(), + b.getHarvestDate(), + b.getBatchCode(), + b.getStatus(), + b.isContaminationFlag() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginRequest.java new file mode 100644 index 0000000..1601a5a --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginRequest.java @@ -0,0 +1,13 @@ +package de.cannamanage.api.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "Email is required") + @Email(message = "Must be a valid email address") + String email, + + @NotBlank(message = "Password is required") + String password +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginResponse.java new file mode 100644 index 0000000..a425607 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/LoginResponse.java @@ -0,0 +1,8 @@ +package de.cannamanage.api.dto.auth; + +public record LoginResponse( + String accessToken, + String refreshToken, + long expiresIn, + String role +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/RefreshRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/RefreshRequest.java new file mode 100644 index 0000000..b381ae9 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/RefreshRequest.java @@ -0,0 +1,8 @@ +package de.cannamanage.api.dto.auth; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshRequest( + @NotBlank(message = "Refresh token is required") + String refreshToken +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/compliance/QuotaResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/compliance/QuotaResponse.java new file mode 100644 index 0000000..e620243 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/compliance/QuotaResponse.java @@ -0,0 +1,12 @@ +package de.cannamanage.api.dto.compliance; + +import java.math.BigDecimal; + +public record QuotaResponse( + BigDecimal totalAllowed, + BigDecimal totalUsed, + BigDecimal remaining, + boolean under21, + int year, + int month +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/CreateDistributionRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/CreateDistributionRequest.java new file mode 100644 index 0000000..b1c036b --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/CreateDistributionRequest.java @@ -0,0 +1,21 @@ +package de.cannamanage.api.dto.distribution; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.UUID; + +public record CreateDistributionRequest( + @NotNull(message = "Member ID is required") + UUID memberId, + + @NotNull(message = "Batch ID is required") + UUID batchId, + + @NotNull(message = "Quantity in grams is required") + @DecimalMin(value = "0.01", message = "Quantity must be greater than zero") + BigDecimal quantityGrams, + + String notes +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/DistributionResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/DistributionResponse.java new file mode 100644 index 0000000..838d99c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/distribution/DistributionResponse.java @@ -0,0 +1,15 @@ +package de.cannamanage.api.dto.distribution; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record DistributionResponse( + UUID id, + UUID memberId, + UUID batchId, + BigDecimal quantityGrams, + Instant distributedAt, + UUID recordedBy, + String notes +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/CreateMemberRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/CreateMemberRequest.java new file mode 100644 index 0000000..2a8e895 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/CreateMemberRequest.java @@ -0,0 +1,30 @@ +package de.cannamanage.api.dto.member; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Past; + +import java.time.LocalDate; + +public record CreateMemberRequest( + @NotBlank(message = "First name is required") + String firstName, + + @NotBlank(message = "Last name is required") + String lastName, + + @NotBlank(message = "Email is required") + @Email(message = "Must be a valid email") + String email, + + @NotNull(message = "Date of birth is required") + @Past(message = "Date of birth must be in the past") + LocalDate dateOfBirth, + + @NotNull(message = "Membership date is required") + LocalDate membershipDate, + + @NotBlank(message = "Membership number is required") + String membershipNumber +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/MemberResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/MemberResponse.java new file mode 100644 index 0000000..e37c016 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/MemberResponse.java @@ -0,0 +1,19 @@ +package de.cannamanage.api.dto.member; + +import de.cannamanage.domain.enums.MemberStatus; + +import java.time.LocalDate; +import java.util.UUID; + +public record MemberResponse( + UUID id, + String firstName, + String lastName, + String email, + LocalDate dateOfBirth, + LocalDate membershipDate, + String membershipNumber, + MemberStatus status, + boolean under21, + boolean preventionOfficer +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/UpdateMemberRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/UpdateMemberRequest.java new file mode 100644 index 0000000..68151d1 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/member/UpdateMemberRequest.java @@ -0,0 +1,17 @@ +package de.cannamanage.api.dto.member; + +import jakarta.validation.constraints.Email; + +import java.time.LocalDate; + +public record UpdateMemberRequest( + String firstName, + String lastName, + + @Email(message = "Must be a valid email") + String email, + + LocalDate dateOfBirth, + String membershipNumber, + String status +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/BatchResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/BatchResponse.java new file mode 100644 index 0000000..5ce43df --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/BatchResponse.java @@ -0,0 +1,17 @@ +package de.cannamanage.api.dto.stock; + +import de.cannamanage.domain.enums.BatchStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record BatchResponse( + UUID id, + UUID strainId, + BigDecimal quantityGrams, + LocalDate harvestDate, + String batchCode, + BatchStatus status, + boolean contaminationFlag +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/CreateBatchRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/CreateBatchRequest.java new file mode 100644 index 0000000..7b069bf --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/stock/CreateBatchRequest.java @@ -0,0 +1,23 @@ +package de.cannamanage.api.dto.stock; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record CreateBatchRequest( + @NotNull(message = "Strain ID is required") + UUID strainId, + + @NotNull(message = "Quantity in grams is required") + @DecimalMin(value = "0.01", message = "Quantity must be greater than zero") + BigDecimal quantityGrams, + + LocalDate harvestDate, + + @NotBlank(message = "Batch code is required") + String batchCode +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java index ada43eb..3cabf06 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/exception/GlobalExceptionHandler.java @@ -1,5 +1,9 @@ package de.cannamanage.api.exception; +import de.cannamanage.api.service.AuthService; +import de.cannamanage.service.exception.BatchNotFoundException; +import de.cannamanage.service.exception.MemberNotFoundException; +import de.cannamanage.service.exception.QuotaExceededException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; @@ -8,9 +12,11 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import java.net.URI; import java.time.Instant; +import java.util.stream.Collectors; /** * Global exception handler producing application/problem+json responses. @@ -20,6 +26,17 @@ import java.time.Instant; @RestControllerAdvice public class GlobalExceptionHandler { + @ExceptionHandler(AuthService.AuthenticationException.class) + public ProblemDetail handleAuthException(AuthService.AuthenticationException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.UNAUTHORIZED, ex.getMessage()); + problem.setTitle("Authentication Failed"); + problem.setType(URI.create("urn:cannamanage:error:AUTHENTICATION_FAILED")); + problem.setProperty("code", "AUTHENTICATION_FAILED"); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + @ExceptionHandler(BadCredentialsException.class) public ProblemDetail handleBadCredentials(BadCredentialsException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( @@ -51,20 +68,51 @@ public class GlobalExceptionHandler { problem.setProperty("code", "VALIDATION_FAILED"); problem.setProperty("timestamp", Instant.now().toString()); - var fieldErrors = ex.getBindingResult().getFieldErrors().stream() + var errors = ex.getBindingResult().getFieldErrors().stream() .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) - .toList(); - problem.setProperty("errors", fieldErrors); + .collect(Collectors.toList()); + problem.setProperty("errors", errors); return problem; } - @ExceptionHandler(IllegalArgumentException.class) - public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + @ExceptionHandler(QuotaExceededException.class) + public ProblemDetail handleQuotaExceeded(QuotaExceededException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( - HttpStatus.BAD_REQUEST, ex.getMessage()); - problem.setTitle("Bad Request"); - problem.setType(URI.create("urn:cannamanage:error:BAD_REQUEST")); - problem.setProperty("code", "BAD_REQUEST"); + HttpStatus.CONFLICT, ex.getMessage()); + problem.setTitle("Compliance Violation"); + problem.setType(URI.create("urn:cannamanage:error:QUOTA_EXCEEDED")); + problem.setProperty("code", ex.getCode().name()); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + + @ExceptionHandler(MemberNotFoundException.class) + public ProblemDetail handleMemberNotFound(MemberNotFoundException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.NOT_FOUND, ex.getMessage()); + problem.setTitle("Not Found"); + problem.setType(URI.create("urn:cannamanage:error:MEMBER_NOT_FOUND")); + problem.setProperty("code", "MEMBER_NOT_FOUND"); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + + @ExceptionHandler(BatchNotFoundException.class) + public ProblemDetail handleBatchNotFound(BatchNotFoundException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.NOT_FOUND, ex.getMessage()); + problem.setTitle("Not Found"); + problem.setType(URI.create("urn:cannamanage:error:BATCH_NOT_FOUND")); + problem.setProperty("code", "BATCH_NOT_FOUND"); + problem.setProperty("timestamp", Instant.now().toString()); + return problem; + } + + @ExceptionHandler(ResponseStatusException.class) + public ProblemDetail handleResponseStatus(ResponseStatusException ex) { + ProblemDetail problem = ProblemDetail.forStatusAndDetail( + HttpStatus.valueOf(ex.getStatusCode().value()), ex.getReason()); + problem.setTitle(HttpStatus.valueOf(ex.getStatusCode().value()).getReasonPhrase()); problem.setProperty("timestamp", Instant.now().toString()); return problem; } @@ -75,7 +123,7 @@ public class GlobalExceptionHandler { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"); problem.setTitle("Internal Server Error"); - problem.setType(URI.create("urn:cannamanage:error:INTERNAL")); + problem.setType(URI.create("urn:cannamanage:error:INTERNAL_ERROR")); problem.setProperty("code", "INTERNAL_ERROR"); problem.setProperty("timestamp", Instant.now().toString()); return problem; diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java index 6e48120..bb030de 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -16,9 +14,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** - * Dual SecurityFilterChain configuration: - * - /api/** → stateless JWT (Bearer token) - * - /portal/** → session-based (future Sprint 3) + * Security configuration — Sprint 2: API-only with JWT. + * Roles: ADMIN (full access) + MEMBER (self-service endpoints only). + * STAFF role reserved for Sprint 3. */ @Configuration @EnableWebSecurity @@ -43,12 +41,11 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") - .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF") - .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF") - .requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF") + .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/reports/**").hasRole("ADMIN") - .requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF") - .requestMatchers("/api/v1/me/**").authenticated() .anyRequest().authenticated()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); @@ -73,9 +70,4 @@ public class SecurityConfig { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { - return config.getAuthenticationManager(); - } } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/TenantFilterAspect.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/TenantFilterAspect.java new file mode 100644 index 0000000..5401d9d --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/TenantFilterAspect.java @@ -0,0 +1,43 @@ +package de.cannamanage.api.security; + +import de.cannamanage.domain.entity.TenantContext; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.hibernate.Session; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * CRITICAL FIX: Activates the Hibernate @Filter("tenantFilter") on every repository call. + * Without this, the filter defined on AbstractTenantEntity is never enabled, + * meaning ALL queries return data across ALL tenants — a severe data leak. + * + * This aspect intercepts every Spring Data JPA repository method and enables + * the tenant filter with the current tenant ID from TenantContext. + */ +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class TenantFilterAspect { + + private final EntityManager entityManager; + + @Before("execution(* de.cannamanage.service.repository.*.*(..))") + public void activateTenantFilter() { + UUID tenantId = TenantContext.getCurrentTenant(); + if (tenantId == null) { + log.trace("No tenant in context — filter not activated (public endpoint or system call)"); + return; + } + + Session session = entityManager.unwrap(Session.class); + session.enableFilter("tenantFilter") + .setParameter("tenantId", tenantId); + log.trace("Tenant filter activated for tenant {}", tenantId); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java new file mode 100644 index 0000000..c874c5e --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java @@ -0,0 +1,103 @@ +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.security.JwtService; +import de.cannamanage.domain.entity.User; +import de.cannamanage.service.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.UUID; + +/** + * Authentication service — handles login and token refresh. + * Stateless JWT approach: no UserDetailsService needed. + * Refresh tokens are hashed and stored on the User entity for revocation support. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + + @Transactional + public LoginResponse login(LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new AuthenticationException("Invalid credentials")); + + if (!user.isActive()) { + throw new AuthenticationException("Account is disabled"); + } + + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new AuthenticationException("Invalid credentials"); + } + + // Generate tokens + String roleName = user.getRole().name().replace("ROLE_", ""); + String accessToken = jwtService.generateAccessToken( + user.getId(), user.getTenantId(), roleName, user.getEmail()); + String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); + + // Store hashed refresh token for revocation + user.setRefreshTokenHash(passwordEncoder.encode(refreshToken)); + user.setLastLogin(Instant.now()); + userRepository.save(user); + + log.info("User {} logged in for tenant {}", user.getEmail(), user.getTenantId()); + + return new LoginResponse(accessToken, refreshToken, 3600L, roleName); + } + + @Transactional + public LoginResponse refresh(RefreshRequest request) { + String token = request.refreshToken(); + + if (!jwtService.isTokenValid(token)) { + throw new AuthenticationException("Invalid or expired refresh token"); + } + + UUID userId = jwtService.extractUserId(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthenticationException("User not found")); + + if (!user.isActive()) { + throw new AuthenticationException("Account is disabled"); + } + + // Verify the refresh token matches stored hash (revocation check) + if (user.getRefreshTokenHash() == null || + !passwordEncoder.matches(token, user.getRefreshTokenHash())) { + throw new AuthenticationException("Refresh token has been revoked"); + } + + // Rotate refresh token + String roleName = user.getRole().name().replace("ROLE_", ""); + String newAccessToken = jwtService.generateAccessToken( + user.getId(), user.getTenantId(), roleName, user.getEmail()); + String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); + + user.setRefreshTokenHash(passwordEncoder.encode(newRefreshToken)); + userRepository.save(user); + + return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName); + } + + /** + * Custom authentication exception — caught by GlobalExceptionHandler. + */ + public static class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + } + } +} diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index a65b2db..f25a61e 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -1,6 +1,7 @@ spring.application.name=cannamanage # Default profile — override with -Dspring.profiles.active=local spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity spring.flyway.enabled=false # JWT Security @@ -13,3 +14,7 @@ springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.path=/swagger-ui.html springdoc.swagger-ui.tags-sorter=alpha springdoc.swagger-ui.operations-sorter=method + +# Enable Spring AOP for TenantFilterAspect +spring.aop.auto=true +spring.aop.proxy-target-class=true diff --git a/cannamanage-api/src/main/resources/db/migration/V2__sprint2_role_rename_and_indexes.sql b/cannamanage-api/src/main/resources/db/migration/V2__sprint2_role_rename_and_indexes.sql new file mode 100644 index 0000000..6727a5e --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V2__sprint2_role_rename_and_indexes.sql @@ -0,0 +1,9 @@ +-- CannaManage V2 — Sprint 2 schema adjustments +-- 1. Rename ROLE_MANAGER → ROLE_STAFF in users table +-- 2. Add index on users.email for login lookup + +UPDATE users SET role = 'ROLE_STAFF' WHERE role = 'ROLE_MANAGER'; + +-- Optimize login queries +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_tenant_email ON users(tenant_id, email); diff --git a/cannamanage-api/src/test/resources/application-test.properties b/cannamanage-api/src/test/resources/application-test.properties new file mode 100644 index 0000000..950cc47 --- /dev/null +++ b/cannamanage-api/src/test/resources/application-test.properties @@ -0,0 +1,19 @@ +spring.application.name=cannamanage-test +spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver + +# Let Hibernate create schema from entities (H2 doesn't support all Postgres DDL) +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.flyway.enabled=false + +# JWT test secret +cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI= +cannamanage.security.jwt.access-token-expiry=3600 +cannamanage.security.jwt.refresh-token-expiry=2592000 + +# AOP +spring.aop.auto=true +spring.aop.proxy-target-class=true diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java index b29e1d4..814b10c 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/UserRole.java @@ -1,8 +1,12 @@ package de.cannamanage.domain.enums; +/** + * User roles for access control. + * Sprint 2: ADMIN + MEMBER only. + * Sprint 3: STAFF added (replaces old MANAGER concept). + */ public enum UserRole { ROLE_ADMIN, - ROLE_MANAGER, - ROLE_MEMBER, - ROLE_PREVENTION_OFFICER + ROLE_STAFF, + ROLE_MEMBER } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/UserRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/UserRepository.java new file mode 100644 index 0000000..71d6a3f --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/UserRepository.java @@ -0,0 +1,18 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmailAndTenantId(String email, UUID tenantId); + + Optional findByEmail(String email); + + boolean existsByEmailAndTenantId(String email, UUID tenantId); +} diff --git a/docs/sprint-2/cannamanage-sprint2-plan.md b/docs/sprint-2/cannamanage-sprint2-plan.md new file mode 100644 index 0000000..a2fcef7 --- /dev/null +++ b/docs/sprint-2/cannamanage-sprint2-plan.md @@ -0,0 +1,381 @@ +# CannaManage — Sprint 2 Architecture Review & Implementation Plan + +**Date:** 2026-06-11 +**Author:** Patrick Plate / Lumen (Planner) +**Status:** Draft v1 — awaiting GO +**Branch:** `sprint/2-api` +**Sprint Goal:** REST API layer + Spring Security + OpenAPI + Tenant Filter integration + +--- + +## 1. Current State Assessment + +### 1.1 Sprint 1 Deliverables (Complete ✅) + +| Component | Status | Notes | +|-----------|--------|-------| +| 8 JPA entities | ✅ | `Club`, `Member`, `Batch`, `Distribution`, `MonthlyQuota`, `StockMovement`, `Strain`, `User` | +| `AbstractTenantEntity` | ✅ | Hibernate `@Filter` + `@PrePersist` sets `tenantId` from `TenantContext` | +| `TenantContext` | ✅ | ThreadLocal UUID holder | +| `ComplianceService` | ✅ | Full CanG §19 enforcement: daily/monthly/batch/THC checks | +| 5 repositories | ✅ | `Member`, `Distribution`, `Batch`, `MonthlyQuota`, `Strain` | +| 25 tests, 100% JaCoCo | ✅ | | + +### 1.2 Sprint 2 Draft Code (Exists — Needs Revision) + +| File | Status | Issues Identified | +|------|--------|-------------------| +| `JwtService.java` | ⚠️ Functional but needs decisions | Token expiry: 1h access + 30d refresh (matches API spec) | +| `JwtAuthFilter.java` | ⚠️ Good pattern | Sets TenantContext correctly, cleans up in `finally` | +| `SecurityConfig.java` | ⚠️ Needs role alignment | Uses `STAFF` role not defined in `UserRole` enum | +| `GlobalExceptionHandler.java` | ✅ Solid | RFC 9457 ProblemDetail pattern, covers key exceptions | +| `application.properties` | ⚠️ | Dev secret hardcoded (acceptable for dev), `flyway.enabled=false` | + +--- + +## 2. Architecture Conflicts Identified + +### 🔴 CONFLICT 1: Multi-Tenancy Model Mismatch + +**Architecture Doc says:** Schema-per-tenant (`CREATE SCHEMA tenant_{id}`, `SET search_path`) +**Sprint 1 implemented:** Shared-schema with Hibernate `@Filter` on `tenant_id` column + +**Analysis:** +The architecture doc (Section 2) explicitly argues for schema-per-tenant with a `TenantRoutingDataSource`. However, Sprint 1 actually implemented a **shared-schema approach** using `@FilterDef`/`@Filter` on `AbstractTenantEntity`. Every entity has a `tenant_id` column and `@PrePersist` sets it from `TenantContext`. + +**Impact:** This is not a bug — it's a pragmatic MVP decision. Schema-per-tenant is operationally complex for a solo dev. The shared-schema + Hibernate filter approach works for MVP scale (<50 clubs). The code is internally consistent. + +**Decision needed:** +- **Option A:** Keep shared-schema + `@Filter` for MVP (recommended — less ops complexity) +- **Option B:** Refactor to schema-per-tenant as documented (correct long-term, but 2-3 days of plumbing) + +**Recommendation:** Option A. The Hibernate `@Filter` approach works and the data model already has `tenant_id` everywhere. We can migrate to schema-per-tenant in v2 when onboarding > 50 clubs. The critical invariant — tenant isolation — is preserved either way. + +--- + +### 🟡 CONFLICT 2: Role Model Divergence + +**Architecture Doc roles:** `ROLE_CLUB_ADMIN`, `ROLE_STAFF`, `ROLE_MEMBER`, `ROLE_PREVENTION_OFFICER` +**API Spec roles:** `ADMIN`, `MEMBER` (only two roles mentioned in endpoints) +**Sprint 1 UserRole enum:** `ROLE_ADMIN`, `ROLE_MANAGER`, `ROLE_MEMBER`, `ROLE_PREVENTION_OFFICER` +**SecurityConfig draft:** References `ADMIN`, `STAFF`, `MEMBER` + +**Problems:** +1. `ROLE_MANAGER` exists in the enum but is not in the architecture doc (which has `ROLE_STAFF`) +2. `SecurityConfig` references `STAFF` but the enum has `MANAGER` +3. The API spec only distinguishes `ADMIN` and `MEMBER` — no STAFF endpoints at all +4. Architecture doc defines a `StaffPermission` enum with fine-grained permissions — not implemented + +**Decision needed:** +- **Option A:** Sprint 2 MVP uses only ADMIN + MEMBER (matches API spec, simplest) +- **Option B:** Implement ADMIN + STAFF + MEMBER now (matches architecture doc intent) +- **Option C:** Full permission model with `StaffPermission` JSONB column + +**Recommendation:** Option A for Sprint 2. The API spec only defines ADMIN and MEMBER roles with clear permission boundaries. STAFF with configurable permissions (Option C) is a Sprint 3/4 feature — it requires a `staff_accounts` table, permission grants, and a custom `PermissionEvaluator`. The `ROLE_PREVENTION_OFFICER` is effectively a member with extra read access to under-21 data — can be a flag on the User entity rather than a separate role. + +--- + +### 🟡 CONFLICT 3: Token Expiry Discrepancy + +**Architecture Doc:** 8-hour access token +**API Spec:** 1-hour access token (`expiresIn: 3600`) +**Current Code:** 1-hour (matches API spec) + +**Recommendation:** Keep 1-hour. Shorter tokens are more secure for a compliance SaaS handling personal data. The refresh token (30 days) handles UX. The architecture doc value was aspirational; the API spec value is the contract. + +--- + +### 🟢 NON-ISSUE: Spring Boot 4.0.6 + Spring Security 7 + +The current code is already on Boot 4.0.6. Key observations: + +1. **`SecurityFilterChain` pattern** — already used correctly (no deprecated `WebSecurityConfigurerAdapter`) +2. **`SessionCreationPolicy.STATELESS`** — correct for JWT API +3. **JJWT 0.12.6** — latest stable, works with Boot 4 +4. **Spring Security 7 modularization** — `spring-security-access` module may be needed if using `@PreAuthorize`. Boot starter pulls it in transitively. +5. **`springdoc-openapi-starter-webmvc-ui`** — compatible with Boot 4.x (version needs checking in POM) + +--- + +## 3. Sprint 2 Scope + +### ✅ IN Scope + +| # | Feature | User Stories | Priority | +|---|---------|-------------|----------| +| 1 | **Auth Controller** — login, refresh, logout | US-011 | Must | +| 2 | **Member Controller** — CRUD + quota endpoint | US-002, US-014 | Must | +| 3 | **Distribution Controller** — create + list | US-003, US-004 | Must | +| 4 | **Stock Controller** — strains CRUD, batches CRUD, recall | US-005, US-009 | Must | +| 5 | **Compliance Controller** — quota check, dry-run check | US-004 | Must | +| 6 | **Report Controller** — monthly JSON (PDF deferred) | US-007 | Must (JSON only) | +| 7 | **Club Controller** — /clubs/me, /clubs/me/stats | US-006 | Must | +| 8 | **OpenAPI/Swagger UI** — full API documentation | — | Must | +| 9 | **Hibernate Filter activation** — enable `tenantFilter` on every request | — | Must | +| 10 | **Integration tests** — MockMvc + H2 for all controllers | — | Must | + +### ❌ OUT of Scope (Sprint 3+) + +| Feature | Reason | +|---------|--------| +| STAFF role + StaffPermission model | Requires separate table + custom PermissionEvaluator | +| PDF/CSV report generation | Requires iText 7 dependency + report templates | +| Member portal (session-based auth) | Different security chain, Sprint 3 | +| Email notifications | Requires Jakarta Mail setup | +| Stripe integration | v2 feature (US-015) | +| Rate limiting on login | Nice-to-have, can add via Spring Cloud Gateway or `bucket4j` later | +| CORS configuration | Needs frontend domain — add when React app exists | +| Schema-per-tenant migration | v2 operational maturity | + +--- + +## 4. Architecture Decisions + +### AD-01: Shared-Schema Multi-Tenancy (Keep Current) + +**Context:** Architecture doc prescribes schema-per-tenant, Sprint 1 uses Hibernate `@Filter`. +**Decision:** Keep `@Filter` approach for MVP. +**Rationale:** Reduces ops complexity for solo dev. Hibernate filters are reliable when properly activated. The data model is already correct — migration to schema-per-tenant later only requires infrastructure changes, not entity redesign. +**Risk:** If the filter is not enabled on a session, queries return cross-tenant data. Mitigation: the `TenantFilterAspect` (new component, see Step 4 below) will guarantee activation. + +### AD-02: Two-Role Model for Sprint 2 + +**Context:** Architecture doc defines 4 roles + configurable staff permissions. +**Decision:** Sprint 2 uses `ADMIN` and `MEMBER` only. +**Rationale:** API spec only documents these two. SecurityConfig URL rules are sufficient. `@PreAuthorize` annotations on controller methods provide method-level granularity. +**Consequence:** Rename `UserRole.ROLE_MANAGER` → remove it. Keep `ROLE_PREVENTION_OFFICER` as a marker for later but don't enforce it in Sprint 2 security rules. + +### AD-03: JwtService as Single Source of Auth Truth + +**Context:** No UserDetailsService loading from DB on every request. +**Decision:** JWT claims are sufficient for authorization. No DB hit per request. +**Rationale:** Stateless JWT. The token contains `sub` (userId), `tenant_id`, and `role`. These are all SecurityConfig needs. If we need to check `user.active` in real-time, we'll add a refresh-token-revocation mechanism (invalidate refresh token → user is forced to re-login within 1 hour). +**Tradeoff:** A deactivated user remains authenticated until their access token expires (max 1 hour). Acceptable for MVP. + +### AD-04: ProblemDetail (RFC 9457) for All Errors + +**Context:** API spec defines a custom error format with `status`, `error`, `code`, `message`, `timestamp`, `path`. +**Decision:** Use Spring's native `ProblemDetail` + custom properties. +**Rationale:** Already implemented in `GlobalExceptionHandler`. Native Spring Boot support since 3.x. The custom error codes (QUOTA_EXCEEDED_MONTHLY, etc.) map to `ProblemDetail.setProperty("code", ...)`. + +### AD-05: No Custom UserDetailsService + +**Context:** Spring Security typically expects a `UserDetailsService` bean. +**Decision:** AuthController handles login directly via repository lookup + BCrypt verification. No Spring Security `UserDetailsService`. +**Rationale:** For JWT-only auth, the authentication manager is only needed at login time. The `JwtAuthFilter` bypasses Spring's standard auth flow entirely — it sets the SecurityContext directly from JWT claims. This is cleaner for stateless APIs. + +--- + +## 5. Implementation Steps (Ordered) + +### Phase 1: Foundation Fixes (Day 1) + +| Step | What | Files | +|------|------|-------| +| 1.1 | Fix `UserRole` enum — rename MANAGER→STAFF, keep PREVENTION_OFFICER as future placeholder | `cannamanage-domain/.../enums/UserRole.java` | +| 1.2 | Add `UserRepository` to service module | `cannamanage-service/.../repository/UserRepository.java` | +| 1.3 | Create `TenantFilterAspect` — AOP aspect that enables Hibernate filter on `EntityManager` before every `@Transactional` service call | `cannamanage-service/.../config/TenantFilterAspect.java` | +| 1.4 | Add `updatedAt` field to `AbstractTenantEntity` (currently missing vs. architecture doc) | `cannamanage-domain/.../entity/AbstractTenantEntity.java` | +| 1.5 | Create H2 dev profile with `spring.flyway.enabled=true` + initial migration | `cannamanage-api/src/main/resources/application-dev.properties`, `db/migration/V1__init.sql` | + +### Phase 2: Auth Controller (Day 1-2) + +| Step | What | Files | +|------|------|-------| +| 2.1 | Create `AuthController` with `POST /login`, `POST /refresh`, `POST /logout` | `cannamanage-api/.../controller/AuthController.java` | +| 2.2 | Create `LoginRequest`, `LoginResponse`, `RefreshRequest`, `RefreshResponse` DTOs | `cannamanage-api/.../dto/auth/` | +| 2.3 | Create `AuthService` — handles login logic, password verification, refresh token rotation | `cannamanage-service/.../AuthService.java` | +| 2.4 | Add domain exception handlers for `QuotaExceededException`, `MemberNotFoundException`, `BatchNotFoundException` to `GlobalExceptionHandler` | `cannamanage-api/.../exception/GlobalExceptionHandler.java` | + +### Phase 3: Member Controller (Day 2-3) + +| Step | What | Files | +|------|------|-------| +| 3.1 | Create `MemberController` — GET list (paginated), POST create, GET by id, PUT update, DELETE soft-delete | `cannamanage-api/.../controller/MemberController.java` | +| 3.2 | Create `MemberService` — business logic bridge between controller and repositories | `cannamanage-service/.../MemberService.java` | +| 3.3 | Create DTOs: `CreateMemberRequest`, `UpdateMemberRequest`, `MemberResponse`, `MemberSummaryResponse` | `cannamanage-api/.../dto/member/` | +| 3.4 | Add `GET /members/{id}/quota` endpoint (delegates to ComplianceService) | `MemberController.java` | +| 3.5 | Add `GET /members/me` for MEMBER-role self-view | `MemberController.java` | + +### Phase 4: Distribution Controller (Day 3-4) + +| Step | What | Files | +|------|------|-------| +| 4.1 | Create `DistributionController` — POST create, GET list (paginated + filtered), GET by id, POST /{id}/notes | `cannamanage-api/.../controller/DistributionController.java` | +| 4.2 | Create `DistributionService` — orchestrates ComplianceService + persists Distribution + updates batch stock | `cannamanage-service/.../DistributionService.java` | +| 4.3 | Create DTOs: `CreateDistributionRequest`, `DistributionResponse`, `AddCorrectionNoteRequest` | `cannamanage-api/.../dto/distribution/` | +| 4.4 | Implement batch stock decrement on distribution creation (atomic with compliance check) | `DistributionService.java` | + +### Phase 5: Stock Controller (Day 4-5) + +| Step | What | Files | +|------|------|-------| +| 5.1 | Create `StockController` — strains CRUD + batches CRUD + recall | `cannamanage-api/.../controller/StockController.java` | +| 5.2 | Create `StockService` — batch management, recall workflow | `cannamanage-service/.../StockService.java` | +| 5.3 | Create DTOs: `CreateStrainRequest`, `StrainResponse`, `CreateBatchRequest`, `BatchResponse`, `RecallBatchRequest` | `cannamanage-api/.../dto/stock/` | +| 5.4 | Implement recall — flags batch, returns affected members | `StockService.java` | + +### Phase 6: Compliance + Club + Reports (Day 5-6) + +| Step | What | Files | +|------|------|-------| +| 6.1 | Create `ComplianceController` — `GET /compliance/quota/{memberId}`, `GET /compliance/check` (dry-run) | `cannamanage-api/.../controller/ComplianceController.java` | +| 6.2 | Create `ClubController` — `GET /clubs/me`, `PUT /clubs/me`, `GET /clubs/me/stats` | `cannamanage-api/.../controller/ClubController.java` | +| 6.3 | Create `ClubService` — club profile + dashboard stats aggregation | `cannamanage-service/.../ClubService.java` | +| 6.4 | Create `ReportController` — `GET /reports/monthly?format=json` (JSON only for Sprint 2) | `cannamanage-api/.../controller/ReportController.java` | +| 6.5 | Create DTOs for all above | `cannamanage-api/.../dto/club/`, `.../dto/compliance/`, `.../dto/report/` | + +### Phase 7: OpenAPI + Integration Tests (Day 6-7) + +| Step | What | Files | +|------|------|-------| +| 7.1 | Add `@Tag`, `@Operation`, `@ApiResponse` annotations to all controllers | All controllers | +| 7.2 | Configure `OpenApiConfig` — info, security scheme, server URL | `cannamanage-api/.../config/OpenApiConfig.java` | +| 7.3 | Write `AuthControllerTest` — MockMvc tests for login/refresh/logout | `cannamanage-api/src/test/...` | +| 7.4 | Write `MemberControllerTest` — CRUD + auth + tenant isolation | `cannamanage-api/src/test/...` | +| 7.5 | Write `DistributionControllerTest` — compliance rejection scenarios | `cannamanage-api/src/test/...` | +| 7.6 | Write `StockControllerTest` — batch lifecycle + recall | `cannamanage-api/src/test/...` | +| 7.7 | Write `ComplianceControllerTest` — dry-run check | `cannamanage-api/src/test/...` | +| 7.8 | Ensure JaCoCo stays ≥ 80% on service module | Build config | + +--- + +## 6. Key Technical Patterns + +### 6.1 Tenant Filter Activation (New — Critical) + +The Hibernate `@Filter` requires explicit activation on each `Session`. Currently nothing activates it. We need: + +```java +@Aspect +@Component +@RequiredArgsConstructor +public class TenantFilterAspect { + + private final EntityManager entityManager; + + @Before("@within(org.springframework.stereotype.Service)") + public void enableTenantFilter() { + UUID tenantId = TenantContext.getCurrentTenant(); + if (tenantId != null) { + Session session = entityManager.unwrap(Session.class); + session.enableFilter("tenantFilter") + .setParameter("tenantId", tenantId); + } + } +} +``` + +**Alternative:** A `HandlerInterceptor` that runs after the JwtAuthFilter and activates the filter. Both work — AOP is more robust as it catches service-to-service calls too. + +### 6.2 Controller DTO Mapping Pattern + +```java +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +@Tag(name = "Members", description = "Member management") +public class MemberController { + + private final MemberService memberService; + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.CREATED) + public MemberResponse create(@Valid @RequestBody CreateMemberRequest request) { + return memberService.createMember(request); + } +} +``` + +### 6.3 Pagination Response Pattern + +Use Spring's `Page` mapped to a custom envelope matching the API spec: + +```java +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(Page page) { + return new PageResponse<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } +} +``` + +--- + +## 7. Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|-----------| +| Hibernate `@Filter` not activated → cross-tenant data leak | High (if forgotten) | Critical | `TenantFilterAspect` AOP guarantee + integration tests verifying isolation | +| Spring Security 7 API changes break SecurityConfig | Low | Medium | Code is already using Boot 4.0.6 patterns — verified compatible | +| springdoc-openapi incompatibility with Boot 4 | Medium | Low | Check version in POM, use `2.8.x` which supports Boot 4 | +| JaCoCo coverage drops below 80% with new controllers | Medium | Low | Write MockMvc tests for every endpoint, assert status codes | +| `@PreAuthorize` not working without `@EnableMethodSecurity` | Low | High | Already enabled in SecurityConfig ✅ | +| JWT secret hardcoded in properties committed to Git | Medium (already there) | Medium | Replace with `${JWT_SECRET}` env var pattern before any production use | + +--- + +## 8. Open Questions for Patrick + +1. **Tenancy model:** Architecture doc says schema-per-tenant, Sprint 1 uses shared-schema + Hibernate `@Filter`. I recommend keeping `@Filter` for MVP. Agree? + +2. **Role model for Sprint 2:** Keep it to ADMIN + MEMBER only? Or do you want STAFF (with basic URL-level permissions, not the full `StaffPermission` JSONB model) included now? + +3. **`UserRole` cleanup:** The current enum has `ROLE_MANAGER` which doesn't exist in any doc. Replace with `ROLE_STAFF`? Or just remove and keep ADMIN + MEMBER + PREVENTION_OFFICER for now? + +4. **Test database:** Use H2 in-memory for Sprint 2 tests, or set up Testcontainers PostgreSQL? H2 is simpler for dev speed; Testcontainers is more realistic but slower. + +5. **Flyway migrations:** Should I create the full schema migration now (all 8 entities → DDL), or keep `ddl-auto=update` for rapid iteration and create migrations when stable? + +6. **Member portal auth (Sprint 3 preview):** The architecture doc and SecurityConfig both hint at a session-based `/portal/**` chain. Confirm this is deferred to Sprint 3? + +7. **Prevention Officer:** The architecture doc says this is a separate role with access to under-21 member data. Should this be: + - A separate `UserRole` value with specific `@PreAuthorize` rules? + - Or a boolean flag on the User/Member that grants additional read access via service-layer checks? + +--- + +## 9. Definition of Done — Sprint 2 + +- [ ] All 7 controllers implemented with full CRUD per API spec +- [ ] JWT auth flow working: login → access token → authenticated requests → refresh → logout +- [ ] Tenant isolation verified: integration tests proving cross-tenant queries return 0 results +- [ ] Compliance checks enforced on distribution creation (fail with 422 + custom error codes) +- [ ] OpenAPI Swagger UI accessible at `/swagger-ui.html` with all endpoints documented +- [ ] `@PreAuthorize` annotations on all endpoints with correct role requirements +- [ ] GlobalExceptionHandler covers all domain exceptions with RFC 9457 ProblemDetail +- [ ] MockMvc integration tests for all controllers (≥ 2 tests per endpoint: happy path + auth failure) +- [ ] JaCoCo ≥ 80% on `cannamanage-service`, ≥ 70% on `cannamanage-api` +- [ ] No hardcoded secrets in committed code (move JWT secret to env var pattern) +- [ ] Build passes: `mvn clean verify` + +--- + +## 10. Estimated Effort + +| Phase | Effort | Cumulative | +|-------|--------|------------| +| Phase 1: Foundation Fixes | 3-4 hours | Day 1 | +| Phase 2: Auth Controller | 4-5 hours | Day 1-2 | +| Phase 3: Member Controller | 4-5 hours | Day 2-3 | +| Phase 4: Distribution Controller | 4-5 hours | Day 3-4 | +| Phase 5: Stock Controller | 4-5 hours | Day 4-5 | +| Phase 6: Compliance + Club + Reports | 4-5 hours | Day 5-6 | +| Phase 7: OpenAPI + Tests | 6-8 hours | Day 6-7 | +| **Total** | **~30-37 hours** | **~7 working days** | + +--- + +*This plan covers the REST API backbone. Sprint 3 will address: member portal (session auth), PDF/CSV reports (iText 7), email notifications, and the STAFF permission model.* diff --git a/pom.xml b/pom.xml index 4bbf916..cfc2907 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,8 @@ 21 UTF-8 UTF-8 + + 1.20.4 0.8.13 1.00 @@ -48,6 +50,14 @@ cannamanage-service ${project.version} + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import +