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
+