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>
|
||||
<version>2.8.6</version>
|
||||
</dependency>
|
||||
<!-- H2 for tests -->
|
||||
<!-- H2 for unit tests -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -97,6 +108,11 @@
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
+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
|
||||
Reference in New Issue
Block a user