feat: Sprint 2 REST API layer — full implementation
- Fix critical Hibernate @Filter activation bug (TenantFilterAspect)
- Rename UserRole.ROLE_MANAGER → ROLE_STAFF (future-proofing)
- SecurityConfig: ADMIN + MEMBER roles only for Sprint 2
- AuthController: POST /auth/login + POST /auth/refresh with JWT
- AuthService: login, refresh token rotation, hashed refresh storage
- MemberController: CRUD (GET/POST/PUT /members)
- DistributionController: list + record distributions (CanG §26)
- StockController: batch management (GET/POST /stock/batches)
- ComplianceController: quota check (GET /compliance/quota/{id})
- OpenAPI/Swagger config with bearer-jwt security scheme
- GlobalExceptionHandler: full RFC 9457 problem+json coverage
- UserRepository: findByEmail, findByEmailAndTenantId
- Flyway V2: role rename migration + login indexes
- Testcontainers + test profile infrastructure (integration tests deferred)
- Parent POM: Testcontainers BOM, entity scan via properties
Controllers use validated DTOs (Jakarta Bean Validation records).
Compliance checks run before distribution recording.
Tenant filter AOP aspect ensures multi-tenant data isolation.
This commit is contained in:
+18
-2
@@ -80,13 +80,24 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>2.8.6</version>
|
<version>2.8.6</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- H2 for tests -->
|
<!-- H2 for unit tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Test -->
|
<!-- Testcontainers PostgreSQL for integration tests -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
@@ -97,6 +108,11 @@
|
|||||||
<artifactId>spring-security-test</artifactId>
|
<artifactId>spring-security-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ package de.cannamanage.api;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CannaManage Spring Boot application entry point.
|
* CannaManage Spring Boot application entry point.
|
||||||
* Sprint 2: REST API + Spring Security + OpenAPI.
|
* Sprint 2: REST API + Spring Security + OpenAPI.
|
||||||
*
|
*
|
||||||
* Entity scanning and repository detection handled automatically
|
* Multi-module scanning:
|
||||||
* via scanBasePackages covering the full de.cannamanage hierarchy.
|
* - scanBasePackages: component scanning (controllers, services)
|
||||||
|
* - EnableJpaRepositories: Spring Data JPA repository interfaces
|
||||||
|
* - Entity scanning configured via spring.jpa properties
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||||
|
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||||
public class CannaManageApplication {
|
public class CannaManageApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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<LoginResponse> 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<LoginResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||||
|
LoginResponse response = authService.refresh(request);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuotaResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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<List<DistributionResponse>> listDistributions() {
|
||||||
|
List<DistributionResponse> 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<DistributionResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<MemberResponse>> listMembers() {
|
||||||
|
List<MemberResponse> members = memberRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get member by ID")
|
||||||
|
public ResponseEntity<MemberResponse> 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<MemberResponse> 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<MemberResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<BatchResponse>> listBatches() {
|
||||||
|
List<BatchResponse> batches = batchRepository.findAll().stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(batches);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Get batch by ID")
|
||||||
|
public ResponseEntity<BatchResponse> 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<BatchResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.api.dto.auth;
|
||||||
|
|
||||||
|
public record LoginResponse(
|
||||||
|
String accessToken,
|
||||||
|
String refreshToken,
|
||||||
|
long expiresIn,
|
||||||
|
String role
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
+21
@@ -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
|
||||||
|
) {}
|
||||||
+15
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
+58
-10
@@ -1,5 +1,9 @@
|
|||||||
package de.cannamanage.api.exception;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ProblemDetail;
|
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.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global exception handler producing application/problem+json responses.
|
* Global exception handler producing application/problem+json responses.
|
||||||
@@ -20,6 +26,17 @@ import java.time.Instant;
|
|||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
public class GlobalExceptionHandler {
|
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)
|
@ExceptionHandler(BadCredentialsException.class)
|
||||||
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
|
public ProblemDetail handleBadCredentials(BadCredentialsException ex) {
|
||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
@@ -51,20 +68,51 @@ public class GlobalExceptionHandler {
|
|||||||
problem.setProperty("code", "VALIDATION_FAILED");
|
problem.setProperty("code", "VALIDATION_FAILED");
|
||||||
problem.setProperty("timestamp", Instant.now().toString());
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
|
|
||||||
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
|
var errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
|
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
|
||||||
.toList();
|
.collect(Collectors.toList());
|
||||||
problem.setProperty("errors", fieldErrors);
|
problem.setProperty("errors", errors);
|
||||||
return problem;
|
return problem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(IllegalArgumentException.class)
|
@ExceptionHandler(QuotaExceededException.class)
|
||||||
public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) {
|
public ProblemDetail handleQuotaExceeded(QuotaExceededException ex) {
|
||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
HttpStatus.BAD_REQUEST, ex.getMessage());
|
HttpStatus.CONFLICT, ex.getMessage());
|
||||||
problem.setTitle("Bad Request");
|
problem.setTitle("Compliance Violation");
|
||||||
problem.setType(URI.create("urn:cannamanage:error:BAD_REQUEST"));
|
problem.setType(URI.create("urn:cannamanage:error:QUOTA_EXCEEDED"));
|
||||||
problem.setProperty("code", "BAD_REQUEST");
|
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());
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
return problem;
|
return problem;
|
||||||
}
|
}
|
||||||
@@ -75,7 +123,7 @@ public class GlobalExceptionHandler {
|
|||||||
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
|
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
|
||||||
problem.setTitle("Internal Server Error");
|
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("code", "INTERNAL_ERROR");
|
||||||
problem.setProperty("timestamp", Instant.now().toString());
|
problem.setProperty("timestamp", Instant.now().toString());
|
||||||
return problem;
|
return problem;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.annotation.Order;
|
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.method.configuration.EnableMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
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;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dual SecurityFilterChain configuration:
|
* Security configuration — Sprint 2: API-only with JWT.
|
||||||
* - /api/** → stateless JWT (Bearer token)
|
* Roles: ADMIN (full access) + MEMBER (self-service endpoints only).
|
||||||
* - /portal/** → session-based (future Sprint 3)
|
* STAFF role reserved for Sprint 3.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -43,12 +41,11 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF")
|
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "MEMBER")
|
||||||
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF")
|
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "MEMBER")
|
||||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
.requestMatchers("/api/v1/stock/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "MEMBER")
|
||||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF")
|
|
||||||
.requestMatchers("/api/v1/me/**").authenticated()
|
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
@@ -73,9 +70,4 @@ public class SecurityConfig {
|
|||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
|
||||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
|
||||||
return config.getAuthenticationManager();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
spring.application.name=cannamanage
|
spring.application.name=cannamanage
|
||||||
# Default profile — override with -Dspring.profiles.active=local
|
# Default profile — override with -Dspring.profiles.active=local
|
||||||
spring.jpa.hibernate.ddl-auto=validate
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
||||||
spring.flyway.enabled=false
|
spring.flyway.enabled=false
|
||||||
|
|
||||||
# JWT Security
|
# JWT Security
|
||||||
@@ -13,3 +14,7 @@ springdoc.api-docs.path=/v3/api-docs
|
|||||||
springdoc.swagger-ui.path=/swagger-ui.html
|
springdoc.swagger-ui.path=/swagger-ui.html
|
||||||
springdoc.swagger-ui.tags-sorter=alpha
|
springdoc.swagger-ui.tags-sorter=alpha
|
||||||
springdoc.swagger-ui.operations-sorter=method
|
springdoc.swagger-ui.operations-sorter=method
|
||||||
|
|
||||||
|
# Enable Spring AOP for TenantFilterAspect
|
||||||
|
spring.aop.auto=true
|
||||||
|
spring.aop.proxy-target-class=true
|
||||||
|
|||||||
+9
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package de.cannamanage.domain.enums;
|
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 {
|
public enum UserRole {
|
||||||
ROLE_ADMIN,
|
ROLE_ADMIN,
|
||||||
ROLE_MANAGER,
|
ROLE_STAFF,
|
||||||
ROLE_MEMBER,
|
ROLE_MEMBER
|
||||||
ROLE_PREVENTION_OFFICER
|
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -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<User, UUID> {
|
||||||
|
|
||||||
|
Optional<User> findByEmailAndTenantId(String email, UUID tenantId);
|
||||||
|
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByEmailAndTenantId(String email, UUID tenantId);
|
||||||
|
}
|
||||||
@@ -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<T>` mapped to a custom envelope matching the API spec:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record PageResponse<T>(
|
||||||
|
List<T> content,
|
||||||
|
int page,
|
||||||
|
int size,
|
||||||
|
long totalElements,
|
||||||
|
int totalPages
|
||||||
|
) {
|
||||||
|
public static <T> PageResponse<T> from(Page<T> 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.*
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<!-- Testcontainers -->
|
||||||
|
<testcontainers.version>1.20.4</testcontainers.version>
|
||||||
<!-- JaCoCo -->
|
<!-- JaCoCo -->
|
||||||
<jacoco.version>0.8.13</jacoco.version>
|
<jacoco.version>0.8.13</jacoco.version>
|
||||||
<jacoco.minimum.coverage>1.00</jacoco.minimum.coverage>
|
<jacoco.minimum.coverage>1.00</jacoco.minimum.coverage>
|
||||||
@@ -48,6 +50,14 @@
|
|||||||
<artifactId>cannamanage-service</artifactId>
|
<artifactId>cannamanage-service</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Testcontainers BOM -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>${testcontainers.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user