Compare commits
29 Commits
52251cf711
...
6f7352124d
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f7352124d | |||
| 6319552675 | |||
| 8c969c610f | |||
| 5defe42d67 | |||
| 527e9b1219 | |||
| 55110c95af | |||
| 57f418f7c9 | |||
| 87511e0485 | |||
| c3722ab726 | |||
| 3ca231dc9c | |||
| a29c38756c | |||
| 26a77dd269 | |||
| 2d83c4b8a1 | |||
| 61b0cd92be | |||
| e4698827ee | |||
| b22702317a | |||
| 3211ade5be | |||
| 721503b231 | |||
| cfb38e8fc6 | |||
| aabde17532 | |||
| a539ed9eb2 | |||
| 05fd679c4d | |||
| 4aa27cd4f9 | |||
| 706a6e257b | |||
| 329b7abb18 | |||
| 7fe8d4f707 | |||
| 9aaf771469 | |||
| 27690a836e | |||
| cd77eb6448 |
@@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* CannaManage Spring Boot application entry point.
|
||||
@@ -17,6 +18,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||
@EnableScheduling
|
||||
public class CannaManageApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.AssemblyProtocolService;
|
||||
import de.cannamanage.service.AssemblyService;
|
||||
import de.cannamanage.service.AssemblyService.AgendaItemInput;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for general assembly (Mitgliederversammlung) management.
|
||||
* Admin endpoints require MANAGE_ASSEMBLIES permission.
|
||||
* Portal endpoints allow members to view assemblies they're invited to.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class AssemblyController {
|
||||
|
||||
private final AssemblyService assemblyService;
|
||||
private final AssemblyProtocolService protocolService;
|
||||
private final StaffPermissionChecker permissionChecker;
|
||||
private final MemberRepository memberRepository;
|
||||
|
||||
public AssemblyController(AssemblyService assemblyService,
|
||||
AssemblyProtocolService protocolService,
|
||||
StaffPermissionChecker permissionChecker,
|
||||
MemberRepository memberRepository) {
|
||||
this.assemblyService = assemblyService;
|
||||
this.protocolService = protocolService;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.memberRepository = memberRepository;
|
||||
}
|
||||
|
||||
// === Admin Endpoints ===
|
||||
|
||||
@PostMapping("/assemblies")
|
||||
public ResponseEntity<AssemblyResponse> createAssembly(
|
||||
@Valid @RequestBody CreateAssemblyRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
var clubId = permissionChecker.getClubId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var agendaItems = request.agendaItems() != null
|
||||
? request.agendaItems().stream()
|
||||
.map(a -> new AgendaItemInput(a.title(), a.description(), a.itemType()))
|
||||
.toList()
|
||||
: List.<AgendaItemInput>of();
|
||||
|
||||
var assembly = assemblyService.createAssembly(clubId, request.title(), request.assemblyType(),
|
||||
request.scheduledAt(), request.location(), request.quorumRequired(), userId, agendaItems);
|
||||
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@GetMapping("/assemblies")
|
||||
public ResponseEntity<List<AssemblyResponse>> listAssemblies(@AuthenticationPrincipal UserDetails user) {
|
||||
var clubId = permissionChecker.getClubId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assemblies = assemblyService.getAssemblies(clubId);
|
||||
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/assemblies/{id}")
|
||||
public ResponseEntity<AssemblyDetailResponse> getAssemblyDetail(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.getAssemblyDetail(id);
|
||||
var agendaItems = assemblyService.getAgendaItems(id);
|
||||
var attendees = assemblyService.getAttendees(id);
|
||||
var votes = assemblyService.getVotes(id);
|
||||
var quorum = assemblyService.calculateQuorum(id);
|
||||
|
||||
return ResponseEntity.ok(new AssemblyDetailResponse(
|
||||
toResponse(assembly),
|
||||
agendaItems.stream().map(this::toAgendaResponse).toList(),
|
||||
attendees.stream().map(this::toAttendeeResponse).toList(),
|
||||
votes.stream().map(this::toVoteResponse).toList(),
|
||||
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
|
||||
));
|
||||
}
|
||||
|
||||
@PutMapping("/assemblies/{id}")
|
||||
public ResponseEntity<AssemblyResponse> updateAssembly(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateAssemblyRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.updateAssembly(id, request.title(), request.scheduledAt(),
|
||||
request.location(), request.quorumRequired());
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/invite")
|
||||
public ResponseEntity<AssemblyResponse> sendInvitations(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.sendInvitations(id, userId);
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/cancel")
|
||||
public ResponseEntity<AssemblyResponse> cancelAssembly(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.cancelAssembly(id, userId);
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/start")
|
||||
public ResponseEntity<AssemblyResponse> startAssembly(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.startAssembly(id, userId);
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/complete")
|
||||
public ResponseEntity<AssemblyResponse> completeAssembly(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var assembly = assemblyService.completeAssembly(id, userId);
|
||||
return ResponseEntity.ok(toResponse(assembly));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/attendees")
|
||||
public ResponseEntity<AttendeeResponse> checkInAttendee(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CheckInRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var attendee = assemblyService.checkInAttendee(id, request.memberId(), request.proxyForMemberId());
|
||||
return ResponseEntity.ok(toAttendeeResponse(attendee));
|
||||
}
|
||||
|
||||
@GetMapping("/assemblies/{id}/attendees")
|
||||
public ResponseEntity<List<AttendeeResponse>> listAttendees(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var attendees = assemblyService.getAttendees(id);
|
||||
return ResponseEntity.ok(attendees.stream().map(this::toAttendeeResponse).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/{id}/votes")
|
||||
public ResponseEntity<VoteResponse> createVote(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateVoteRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var vote = assemblyService.createVote(id, request.agendaItemId(), request.title(),
|
||||
request.description(), request.voteType());
|
||||
return ResponseEntity.ok(toVoteResponse(vote));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/votes/{voteId}/cast")
|
||||
public ResponseEntity<VoteResponse> castVote(
|
||||
@PathVariable UUID voteId,
|
||||
@Valid @RequestBody CastVoteRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var userId = permissionChecker.getUserId(user);
|
||||
|
||||
var vote = assemblyService.castVote(voteId, request.memberId(), request.decision(), userId);
|
||||
return ResponseEntity.ok(toVoteResponse(vote));
|
||||
}
|
||||
|
||||
@PostMapping("/assemblies/votes/{voteId}/close")
|
||||
public ResponseEntity<VoteResponse> closeVote(
|
||||
@PathVariable UUID voteId,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
var vote = assemblyService.closeVote(voteId);
|
||||
return ResponseEntity.ok(toVoteResponse(vote));
|
||||
}
|
||||
|
||||
@GetMapping("/assemblies/{id}/protocol")
|
||||
public ResponseEntity<byte[]> downloadProtocol(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
permissionChecker.requirePermission(user, StaffPermission.MANAGE_ASSEMBLIES);
|
||||
|
||||
byte[] pdf = protocolService.generateProtocol(id);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=protokoll-" + id + ".pdf")
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
// === Portal Endpoints ===
|
||||
|
||||
@GetMapping("/portal/assemblies")
|
||||
public ResponseEntity<List<AssemblyResponse>> portalListAssemblies(
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var tenantId = permissionChecker.getTenantId(user);
|
||||
var assemblies = assemblyService.getUpcomingAssemblies(tenantId);
|
||||
return ResponseEntity.ok(assemblies.stream().map(this::toResponse).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/portal/assemblies/{id}")
|
||||
public ResponseEntity<AssemblyDetailResponse> portalGetAssemblyDetail(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
var assembly = assemblyService.getAssemblyDetail(id);
|
||||
var agendaItems = assemblyService.getAgendaItems(id);
|
||||
var attendees = assemblyService.getAttendees(id);
|
||||
var votes = assemblyService.getVotes(id);
|
||||
var quorum = assemblyService.calculateQuorum(id);
|
||||
|
||||
return ResponseEntity.ok(new AssemblyDetailResponse(
|
||||
toResponse(assembly),
|
||||
agendaItems.stream().map(this::toAgendaResponse).toList(),
|
||||
attendees.stream().map(this::toAttendeeResponse).toList(),
|
||||
votes.stream().map(this::toVoteResponse).toList(),
|
||||
new QuorumResponse(quorum.attendees(), quorum.totalMembers(), quorum.required(), quorum.quorumMet())
|
||||
));
|
||||
}
|
||||
|
||||
// === DTOs ===
|
||||
|
||||
record CreateAssemblyRequest(
|
||||
@NotBlank String title,
|
||||
@NotNull AssemblyType assemblyType,
|
||||
@NotNull Instant scheduledAt,
|
||||
String location,
|
||||
Integer quorumRequired,
|
||||
List<AgendaItemRequest> agendaItems
|
||||
) {}
|
||||
|
||||
record AgendaItemRequest(
|
||||
@NotBlank String title,
|
||||
String description,
|
||||
@NotNull AgendaItemType itemType
|
||||
) {}
|
||||
|
||||
record UpdateAssemblyRequest(
|
||||
String title,
|
||||
Instant scheduledAt,
|
||||
String location,
|
||||
Integer quorumRequired
|
||||
) {}
|
||||
|
||||
record CheckInRequest(@NotNull UUID memberId, UUID proxyForMemberId) {}
|
||||
|
||||
record CreateVoteRequest(
|
||||
@NotNull UUID agendaItemId,
|
||||
@NotBlank String title,
|
||||
String description,
|
||||
@NotNull VoteType voteType
|
||||
) {}
|
||||
|
||||
record CastVoteRequest(@NotNull UUID memberId, @NotNull VoteDecision decision) {}
|
||||
|
||||
record AssemblyResponse(
|
||||
UUID id, String title, AssemblyType assemblyType, Instant scheduledAt,
|
||||
String location, AssemblyStatus status, Instant invitationSentAt,
|
||||
Integer quorumRequired, Instant openedAt, Instant closedAt, Instant createdAt
|
||||
) {}
|
||||
|
||||
record AssemblyDetailResponse(
|
||||
AssemblyResponse assembly,
|
||||
List<AgendaItemResponse> agendaItems,
|
||||
List<AttendeeResponse> attendees,
|
||||
List<VoteResponse> votes,
|
||||
QuorumResponse quorum
|
||||
) {}
|
||||
|
||||
record AgendaItemResponse(UUID id, int position, String title, String description, AgendaItemType itemType) {}
|
||||
|
||||
record AttendeeResponse(UUID id, UUID memberId, Instant checkedInAt, UUID proxyForMemberId) {}
|
||||
|
||||
record VoteResponse(UUID id, UUID agendaItemId, String title, String description, VoteType voteType,
|
||||
int yesCount, int noCount, int abstainCount, VoteResult result, Instant votedAt) {}
|
||||
|
||||
record QuorumResponse(long attendees, long totalMembers, int required, boolean quorumMet) {}
|
||||
|
||||
// === Mappers ===
|
||||
|
||||
private AssemblyResponse toResponse(Assembly a) {
|
||||
return new AssemblyResponse(a.getId(), a.getTitle(), a.getAssemblyType(), a.getScheduledAt(),
|
||||
a.getLocation(), a.getStatus(), a.getInvitationSentAt(), a.getQuorumRequired(),
|
||||
a.getOpenedAt(), a.getClosedAt(), a.getCreatedAt());
|
||||
}
|
||||
|
||||
private AgendaItemResponse toAgendaResponse(AssemblyAgendaItem i) {
|
||||
return new AgendaItemResponse(i.getId(), i.getPosition(), i.getTitle(), i.getDescription(), i.getItemType());
|
||||
}
|
||||
|
||||
private AttendeeResponse toAttendeeResponse(AssemblyAttendee a) {
|
||||
return new AttendeeResponse(a.getId(), a.getMemberId(), a.getCheckedInAt(), a.getProxyForMemberId());
|
||||
}
|
||||
|
||||
private VoteResponse toVoteResponse(AssemblyVote v) {
|
||||
return new VoteResponse(v.getId(), v.getAgendaItemId(), v.getTitle(), v.getDescription(),
|
||||
v.getVoteType(), v.getYesCount(), v.getNoCount(), v.getAbstainCount(),
|
||||
v.getResult(), v.getVotedAt());
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import de.cannamanage.api.dto.auth.LoginRequest;
|
||||
import de.cannamanage.api.dto.auth.LoginResponse;
|
||||
import de.cannamanage.api.dto.auth.RefreshRequest;
|
||||
import de.cannamanage.api.dto.auth.SetPasswordRequest;
|
||||
import de.cannamanage.api.security.LoginRateLimiter;
|
||||
import de.cannamanage.api.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -24,10 +27,19 @@ import java.util.Map;
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Login with email + password", description = "Returns JWT access + refresh tokens")
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||
String ip = resolveClientIp(httpRequest);
|
||||
if (!loginRateLimiter.tryAcquire(ip)) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(Map.of(
|
||||
"error", "rate_limited",
|
||||
"message", "Zu viele Anmeldeversuche. Bitte warten Sie eine Minute."
|
||||
));
|
||||
}
|
||||
LoginResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
@@ -46,4 +58,17 @@ public class AuthController {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the originating client IP, honouring X-Forwarded-For when present
|
||||
* (so reverse-proxy / load-balancer setups still get per-client rate limits).
|
||||
*/
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
int comma = xff.indexOf(',');
|
||||
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.bankimport.*;
|
||||
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||
import de.cannamanage.domain.entity.BankImportSession;
|
||||
import de.cannamanage.domain.entity.BankTransaction;
|
||||
import de.cannamanage.domain.entity.CsvColumnMapping;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.bankimport.BankImportService;
|
||||
import de.cannamanage.service.repository.CsvColumnMappingRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — REST endpoints for the bank statement import wizard.
|
||||
*
|
||||
* <p>All endpoints live under {@code /api/v1/finance/import/*}. Access requires
|
||||
* either {@link StaffPermission#FINANCE_IMPORT} or {@link StaffPermission#MANAGE_FINANCES}
|
||||
* (ADMIN role always passes). Tenant scoping is implicit via {@link TenantContext}.
|
||||
*
|
||||
* <p>Endpoint overview:
|
||||
* <ul>
|
||||
* <li>{@code POST /finance/import/sessions} — multipart upload + parse (optional {@code mappingId} query)</li>
|
||||
* <li>{@code GET /finance/import/sessions} — list all sessions for the tenant</li>
|
||||
* <li>{@code GET /finance/import/sessions/{id}} — single session detail</li>
|
||||
* <li>{@code GET /finance/import/sessions/{id}/transactions} — transactions, optional {@code ?status=} filter</li>
|
||||
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/confirm} — create payment from match</li>
|
||||
* <li>{@code POST /finance/import/sessions/{id}/confirm-all} — bulk-confirm high-confidence matches</li>
|
||||
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/assign} — manual member assignment</li>
|
||||
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/skip} — drop transaction with reason</li>
|
||||
* <li>{@code POST /finance/import/sessions/{id}/complete} — seal session (GoBD immutability)</li>
|
||||
* <li>{@code GET /finance/import/csv-mappings} — list saved CSV mapping templates</li>
|
||||
* <li>{@code POST /finance/import/csv-mappings} — create a CSV mapping template</li>
|
||||
* <li>{@code DELETE /finance/import/csv-mappings/{id}} — remove a CSV mapping template</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class BankImportController {
|
||||
|
||||
private final BankImportService bankImportService;
|
||||
private final StaffPermissionChecker permissionChecker;
|
||||
private final CsvColumnMappingRepository mappingRepository;
|
||||
|
||||
public BankImportController(BankImportService bankImportService,
|
||||
StaffPermissionChecker permissionChecker,
|
||||
CsvColumnMappingRepository mappingRepository) {
|
||||
this.bankImportService = bankImportService;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.mappingRepository = mappingRepository;
|
||||
}
|
||||
|
||||
// === Sessions ===
|
||||
|
||||
/**
|
||||
* Upload a bank statement file and parse it. Returns the persisted session with
|
||||
* matching results so the frontend can immediately render the review table.
|
||||
*/
|
||||
@PostMapping(value = "/finance/import/sessions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ImportSessionResponse> uploadSession(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "mappingId", required = false) UUID mappingId,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
requireImportPermission(principal);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
CsvColumnMapping mapping = null;
|
||||
if (mappingId != null) {
|
||||
mapping = mappingRepository.findById(mappingId)
|
||||
.filter(m -> clubId.equals(m.getClubId()))
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
|
||||
}
|
||||
|
||||
BankImportSession session = bankImportService.uploadAndParse(clubId, userId, file, mapping);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ImportSessionResponse.from(session));
|
||||
}
|
||||
|
||||
/** List all import sessions for the current tenant, newest first. */
|
||||
@GetMapping("/finance/import/sessions")
|
||||
public ResponseEntity<List<ImportSessionResponse>> listSessions(@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
List<ImportSessionResponse> sessions = bankImportService.getSessions(clubId).stream()
|
||||
.map(ImportSessionResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(sessions);
|
||||
}
|
||||
|
||||
/** Detail view of a single session. */
|
||||
@GetMapping("/finance/import/sessions/{id}")
|
||||
public ResponseEntity<ImportSessionResponse> getSession(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
BankImportSession session = bankImportService.getSession(id);
|
||||
ensureSameTenant(session.getClubId());
|
||||
return ResponseEntity.ok(ImportSessionResponse.from(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactions belonging to a session, optionally filtered by match status.
|
||||
* Drives the review table (typically called with {@code ?status=MATCHED} then
|
||||
* with no filter for the full audit listing).
|
||||
*/
|
||||
@GetMapping("/finance/import/sessions/{id}/transactions")
|
||||
public ResponseEntity<List<TransactionResponse>> listTransactions(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(value = "status", required = false) MatchStatus status,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
requireImportPermission(principal);
|
||||
BankImportSession session = bankImportService.getSession(id);
|
||||
ensureSameTenant(session.getClubId());
|
||||
|
||||
List<TransactionResponse> txns = bankImportService.getTransactions(id, status).stream()
|
||||
.map(TransactionResponse::from)
|
||||
.toList();
|
||||
return ResponseEntity.ok(txns);
|
||||
}
|
||||
|
||||
/** Confirm a single matched transaction → creates a {@code Payment} via {@code FinanceService}. */
|
||||
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/confirm")
|
||||
public ResponseEntity<TransactionResponse> confirmMatch(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID txnId,
|
||||
@Valid @RequestBody ConfirmRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
requireImportPermission(principal);
|
||||
ensureSameTenant(bankImportService.getSession(id).getClubId());
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
BankTransaction txn = bankImportService.confirmMatch(id, txnId, request.memberId(), userId);
|
||||
return ResponseEntity.ok(TransactionResponse.from(txn));
|
||||
}
|
||||
|
||||
/** Bulk-confirm every {@code MATCHED} transaction with confidence ≥ 90 in the session. */
|
||||
@PostMapping("/finance/import/sessions/{id}/confirm-all")
|
||||
public ResponseEntity<BulkConfirmResponse> confirmAll(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
ensureSameTenant(bankImportService.getSession(id).getClubId());
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
BankImportService.BulkConfirmResult result = bankImportService.confirmAllMatched(id, userId);
|
||||
return ResponseEntity.ok(BulkConfirmResponse.from(result));
|
||||
}
|
||||
|
||||
/** Manual assignment for unmatched transactions — sets {@code MATCHED} 100% but does NOT create a Payment yet. */
|
||||
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/assign")
|
||||
public ResponseEntity<TransactionResponse> assignManually(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID txnId,
|
||||
@Valid @RequestBody AssignRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
requireImportPermission(principal);
|
||||
ensureSameTenant(bankImportService.getSession(id).getClubId());
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
BankTransaction txn = bankImportService.manualAssign(id, txnId, request.memberId(), userId);
|
||||
return ResponseEntity.ok(TransactionResponse.from(txn));
|
||||
}
|
||||
|
||||
/** Skip a transaction (e.g. refund, fee, non-member deposit) — stored with reason for audit trail. */
|
||||
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/skip")
|
||||
public ResponseEntity<TransactionResponse> skipTransaction(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID txnId,
|
||||
@RequestBody(required = false) SkipRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
requireImportPermission(principal);
|
||||
ensureSameTenant(bankImportService.getSession(id).getClubId());
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
String reason = request != null ? request.reason() : null;
|
||||
|
||||
BankTransaction txn = bankImportService.skipTransaction(id, txnId, reason, userId);
|
||||
return ResponseEntity.ok(TransactionResponse.from(txn));
|
||||
}
|
||||
|
||||
/** Seal the session — sets status {@code COMPLETED}, after which no further mutations are permitted (GoBD §147 AO). */
|
||||
@PostMapping("/finance/import/sessions/{id}/complete")
|
||||
public ResponseEntity<ImportSessionResponse> completeSession(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
ensureSameTenant(bankImportService.getSession(id).getClubId());
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
BankImportSession session = bankImportService.completeSession(id, userId);
|
||||
return ResponseEntity.ok(ImportSessionResponse.from(session));
|
||||
}
|
||||
|
||||
// === CSV Column Mappings ===
|
||||
|
||||
/** List saved CSV mapping templates for the current tenant. */
|
||||
@GetMapping("/finance/import/csv-mappings")
|
||||
public ResponseEntity<List<CsvColumnMapping>> listMappings(@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(mappingRepository.findByClubId(clubId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CSV mapping template. If {@code isDefault} is true, the existing
|
||||
* default mapping (if any) is cleared so only one template stays default per club.
|
||||
*/
|
||||
@PostMapping("/finance/import/csv-mappings")
|
||||
public ResponseEntity<CsvColumnMapping> createMapping(@Valid @RequestBody CreateMappingRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
CsvColumnMapping mapping = new CsvColumnMapping();
|
||||
mapping.setClubId(clubId);
|
||||
mapping.setName(request.name());
|
||||
mapping.setDateColumn(request.dateColumn());
|
||||
mapping.setAmountColumn(request.amountColumn());
|
||||
mapping.setReferenceColumn(request.referenceColumn());
|
||||
mapping.setCounterpartyColumn(request.counterpartyColumn());
|
||||
mapping.setIbanColumn(request.ibanColumn());
|
||||
if (request.delimiter() != null) {
|
||||
mapping.setDelimiter(request.delimiter());
|
||||
}
|
||||
if (request.dateFormat() != null) {
|
||||
mapping.setDateFormat(request.dateFormat());
|
||||
}
|
||||
if (request.decimalSeparator() != null) {
|
||||
mapping.setDecimalSeparator(request.decimalSeparator());
|
||||
}
|
||||
if (request.skipHeaderRows() != null) {
|
||||
mapping.setSkipHeaderRows(request.skipHeaderRows());
|
||||
}
|
||||
if (request.encoding() != null) {
|
||||
mapping.setEncoding(request.encoding());
|
||||
}
|
||||
boolean wantsDefault = Boolean.TRUE.equals(request.isDefault());
|
||||
mapping.setIsDefault(wantsDefault);
|
||||
|
||||
if (wantsDefault) {
|
||||
Optional<CsvColumnMapping> existingDefault = mappingRepository.findByClubIdAndIsDefaultTrue(clubId);
|
||||
existingDefault.ifPresent(existing -> {
|
||||
existing.setIsDefault(false);
|
||||
mappingRepository.save(existing);
|
||||
});
|
||||
}
|
||||
|
||||
CsvColumnMapping saved = mappingRepository.save(mapping);
|
||||
log.info("CSV mapping created: id={} name={} club={}", saved.getId(), saved.getName(), clubId);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
|
||||
}
|
||||
|
||||
/** Delete a CSV mapping template — only the owner tenant may delete. */
|
||||
@DeleteMapping("/finance/import/csv-mappings/{id}")
|
||||
public ResponseEntity<Void> deleteMapping(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
requireImportPermission(principal);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
CsvColumnMapping mapping = mappingRepository.findById(id)
|
||||
.filter(m -> clubId.equals(m.getClubId()))
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
|
||||
|
||||
mappingRepository.delete(mapping);
|
||||
log.info("CSV mapping deleted: id={} club={}", id, clubId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/**
|
||||
* Permission gate that accepts either {@link StaffPermission#FINANCE_IMPORT} or
|
||||
* {@link StaffPermission#MANAGE_FINANCES}. ADMIN passes both automatically inside
|
||||
* {@link StaffPermissionChecker}.
|
||||
*/
|
||||
private void requireImportPermission(UserDetails principal) {
|
||||
try {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.FINANCE_IMPORT);
|
||||
} catch (AccessDeniedException denied) {
|
||||
// Fall back to MANAGE_FINANCES — finance admins are implicitly authorized.
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence-in-depth tenant check on top of Hibernate {@code @Filter} —
|
||||
* ensures path-parameter IDs from one tenant cannot reach another tenant's session.
|
||||
*/
|
||||
private void ensureSameTenant(UUID sessionClubId) {
|
||||
UUID currentTenant = TenantContext.getCurrentTenant();
|
||||
if (sessionClubId == null || !sessionClubId.equals(currentTenant)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Import-Session nicht gefunden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.BoardMember;
|
||||
import de.cannamanage.domain.entity.BoardPosition;
|
||||
import de.cannamanage.service.BoardService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class BoardController {
|
||||
|
||||
private final BoardService boardService;
|
||||
|
||||
public BoardController(BoardService boardService) {
|
||||
this.boardService = boardService;
|
||||
}
|
||||
|
||||
// --- Positions ---
|
||||
|
||||
@PostMapping("/board/positions")
|
||||
public ResponseEntity<BoardPosition> createPosition(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String title = (String) body.get("title");
|
||||
String description = (String) body.get("description");
|
||||
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : 0;
|
||||
BoardPosition pos = boardService.createPosition(clubId, title, description, sortOrder);
|
||||
return ResponseEntity.ok(pos);
|
||||
}
|
||||
|
||||
@GetMapping("/board/positions")
|
||||
public ResponseEntity<List<BoardPosition>> getPositions(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getPositions(clubId));
|
||||
}
|
||||
|
||||
@PutMapping("/board/positions/{id}")
|
||||
public ResponseEntity<BoardPosition> updatePosition(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String title = (String) body.get("title");
|
||||
String description = (String) body.get("description");
|
||||
Integer sortOrder = body.containsKey("sortOrder") ? (Integer) body.get("sortOrder") : null;
|
||||
Boolean isActive = body.containsKey("isActive") ? (Boolean) body.get("isActive") : null;
|
||||
BoardPosition pos = boardService.updatePosition(id, title, description, sortOrder, isActive);
|
||||
return ResponseEntity.ok(pos);
|
||||
}
|
||||
|
||||
// --- Board Members ---
|
||||
|
||||
@PostMapping("/board/members")
|
||||
public ResponseEntity<BoardMember> electBoardMember(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
Principal principal) {
|
||||
UUID positionId = UUID.fromString((String) body.get("positionId"));
|
||||
UUID memberId = UUID.fromString((String) body.get("memberId"));
|
||||
LocalDate electedAt = LocalDate.parse((String) body.get("electedAt"));
|
||||
LocalDate termStart = LocalDate.parse((String) body.get("termStart"));
|
||||
LocalDate termEnd = body.get("termEnd") != null ? LocalDate.parse((String) body.get("termEnd")) : null;
|
||||
UUID assemblyId = body.get("assemblyId") != null ? UUID.fromString((String) body.get("assemblyId")) : null;
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
|
||||
BoardMember bm = boardService.electBoardMember(clubId, positionId, memberId,
|
||||
electedAt, termStart, termEnd, assemblyId, userId);
|
||||
return ResponseEntity.ok(bm);
|
||||
}
|
||||
|
||||
@GetMapping("/board")
|
||||
public ResponseEntity<List<BoardMember>> getCurrentBoard(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
|
||||
}
|
||||
|
||||
@GetMapping("/board/history")
|
||||
public ResponseEntity<List<BoardMember>> getBoardHistory(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getBoardHistory(clubId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/board/members/{id}")
|
||||
public ResponseEntity<Void> removeBoardMember(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
Principal principal) {
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
boardService.removeBoardMember(id, userId, clubId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// Portal endpoint
|
||||
@GetMapping("/portal/board")
|
||||
public ResponseEntity<List<BoardMember>> getPortalBoard(@RequestParam UUID clubId) {
|
||||
return ResponseEntity.ok(boardService.getCurrentBoard(clubId));
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.ComplianceDeadline;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.ComplianceArea;
|
||||
import de.cannamanage.domain.enums.ComplianceStatus;
|
||||
import de.cannamanage.service.ComplianceDashboardService;
|
||||
import de.cannamanage.service.RetentionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Compliance Dashboard controller.
|
||||
* Provides traffic-light compliance status, upcoming/overdue deadlines,
|
||||
* and retention management endpoints.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/compliance/dashboard")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Compliance Dashboard", description = "Compliance status overview and retention management")
|
||||
public class ComplianceDashboardController {
|
||||
|
||||
private final ComplianceDashboardService dashboardService;
|
||||
private final RetentionService retentionService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get compliance dashboard status",
|
||||
description = "Returns traffic-light status per compliance area + upcoming and overdue deadlines")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<ComplianceDashboardResponse> getDashboard(
|
||||
@RequestParam(defaultValue = "30") int upcomingDays) {
|
||||
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
Map<ComplianceArea, ComplianceStatus> statusMap = dashboardService.getComplianceStatus(clubId);
|
||||
List<ComplianceDeadline> upcoming = dashboardService.getUpcomingDeadlines(clubId, upcomingDays);
|
||||
List<ComplianceDeadline> overdue = dashboardService.getOverdueDeadlines(clubId);
|
||||
|
||||
return ResponseEntity.ok(new ComplianceDashboardResponse(statusMap, upcoming, overdue));
|
||||
}
|
||||
|
||||
@GetMapping("/retention")
|
||||
@Operation(summary = "Get retention report",
|
||||
description = "Shows what was deleted, what will be deleted, and retention schedule")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<RetentionService.RetentionReport> getRetentionReport() {
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(retentionService.getRetentionReport(clubId));
|
||||
}
|
||||
|
||||
@PostMapping("/retention/preview")
|
||||
@Operation(summary = "Preview retention actions (dry-run)",
|
||||
description = "Shows what WOULD be affected by retention processing without making changes")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<RetentionService.RetentionPreview> previewRetention() {
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(retentionService.previewRetention(clubId));
|
||||
}
|
||||
|
||||
public record ComplianceDashboardResponse(
|
||||
Map<ComplianceArea, ComplianceStatus> status,
|
||||
List<ComplianceDeadline> upcomingDeadlines,
|
||||
List<ComplianceDeadline> overdueDeadlines
|
||||
) {}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.ComplianceDeadline;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.ComplianceArea;
|
||||
import de.cannamanage.service.repository.ComplianceDeadlineRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for compliance deadline management.
|
||||
* Powers the compliance dashboard traffic-light system.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/compliance/deadlines")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Compliance Deadlines", description = "Manage compliance deadlines and due dates")
|
||||
public class ComplianceDeadlineController {
|
||||
|
||||
private final ComplianceDeadlineRepository deadlineRepository;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all deadlines (upcoming + overdue)")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<ComplianceDeadline>> listDeadlines() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(deadlineRepository.findByTenantIdOrderByDueDateAsc(tenantId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a new compliance deadline")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<ComplianceDeadline> createDeadline(@Valid @RequestBody CreateDeadlineRequest request) {
|
||||
ComplianceDeadline deadline = new ComplianceDeadline();
|
||||
deadline.setClubId(request.clubId());
|
||||
deadline.setArea(request.area());
|
||||
deadline.setTitle(request.title());
|
||||
deadline.setDescription(request.description());
|
||||
deadline.setDueDate(request.dueDate());
|
||||
deadline.setIsRecurring(request.isRecurring() != null ? request.isRecurring() : false);
|
||||
deadline.setRecurrenceRule(request.recurrenceRule());
|
||||
|
||||
return ResponseEntity.ok(deadlineRepository.save(deadline));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/complete")
|
||||
@Operation(summary = "Mark a deadline as complete")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<ComplianceDeadline> completeDeadline(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody CompleteDeadlineRequest request) {
|
||||
|
||||
ComplianceDeadline deadline = deadlineRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Deadline not found: " + id));
|
||||
|
||||
deadline.setCompletedAt(Instant.now());
|
||||
deadline.setCompletedBy(request.completedBy());
|
||||
|
||||
return ResponseEntity.ok(deadlineRepository.save(deadline));
|
||||
}
|
||||
|
||||
@GetMapping("/overdue")
|
||||
@Operation(summary = "List overdue (incomplete, past due date) deadlines")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<ComplianceDeadline>> listOverdue() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(
|
||||
deadlineRepository.findByTenantIdAndCompletedAtIsNullOrderByDueDateAsc(tenantId)
|
||||
.stream()
|
||||
.filter(d -> d.getDueDate().isBefore(LocalDate.now()))
|
||||
.toList()
|
||||
);
|
||||
}
|
||||
|
||||
public record CreateDeadlineRequest(
|
||||
UUID clubId,
|
||||
ComplianceArea area,
|
||||
String title,
|
||||
String description,
|
||||
LocalDate dueDate,
|
||||
Boolean isRecurring,
|
||||
String recurrenceRule
|
||||
) {}
|
||||
|
||||
public record CompleteDeadlineRequest(
|
||||
UUID completedBy
|
||||
) {}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.DestructionMethod;
|
||||
import de.cannamanage.domain.enums.TransportStatus;
|
||||
import de.cannamanage.service.repository.*;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for KCanG §22 compliance records:
|
||||
* destruction, transport, propagation sources, and prevention activities.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/compliance")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Compliance Records", description = "KCanG §22 record keeping for destruction, transport, propagation & prevention")
|
||||
public class ComplianceRecordsController {
|
||||
|
||||
private final DestructionRecordRepository destructionRecordRepository;
|
||||
private final TransportRecordRepository transportRecordRepository;
|
||||
private final PropagationSourceRepository propagationSourceRepository;
|
||||
private final PreventionActivityRepository preventionActivityRepository;
|
||||
|
||||
// === Destruction Records ===
|
||||
|
||||
@PostMapping("/destruction-records")
|
||||
@Operation(summary = "Record a cannabis destruction event")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<DestructionRecord> recordDestruction(@Valid @RequestBody CreateDestructionRequest request) {
|
||||
DestructionRecord record = new DestructionRecord();
|
||||
record.setClubId(request.clubId());
|
||||
record.setBatchId(request.batchId());
|
||||
record.setAmountGrams(request.amountGrams());
|
||||
record.setDestructionMethod(request.destructionMethod());
|
||||
record.setDescription(request.description());
|
||||
record.setDestroyedAt(request.destroyedAt() != null ? request.destroyedAt() : Instant.now());
|
||||
record.setWitnessedBy(request.witnessedBy());
|
||||
record.setWitnessName(request.witnessName());
|
||||
record.setRecordedBy(request.recordedBy());
|
||||
|
||||
return ResponseEntity.ok(destructionRecordRepository.save(record));
|
||||
}
|
||||
|
||||
@GetMapping("/destruction-records")
|
||||
@Operation(summary = "List destruction records for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<DestructionRecord>> listDestructionRecords() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(destructionRecordRepository.findByTenantIdOrderByDestroyedAtDesc(tenantId));
|
||||
}
|
||||
|
||||
// === Transport Records ===
|
||||
|
||||
@PostMapping("/transport-records")
|
||||
@Operation(summary = "Record a cannabis transport event")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<TransportRecord> recordTransport(@Valid @RequestBody CreateTransportRequest request) {
|
||||
TransportRecord record = new TransportRecord();
|
||||
record.setClubId(request.clubId());
|
||||
record.setDescription(request.description());
|
||||
record.setTransportDate(request.transportDate());
|
||||
record.setFromLocation(request.fromLocation());
|
||||
record.setToLocation(request.toLocation());
|
||||
record.setCarrierName(request.carrierName());
|
||||
record.setAmountGrams(request.amountGrams());
|
||||
record.setBatchId(request.batchId());
|
||||
record.setStatus(request.status() != null ? request.status() : TransportStatus.PLANNED);
|
||||
record.setRecordedBy(request.recordedBy());
|
||||
|
||||
return ResponseEntity.ok(transportRecordRepository.save(record));
|
||||
}
|
||||
|
||||
@GetMapping("/transport-records")
|
||||
@Operation(summary = "List transport records for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<TransportRecord>> listTransportRecords() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(transportRecordRepository.findByTenantIdOrderByTransportDateDesc(tenantId));
|
||||
}
|
||||
|
||||
// === Propagation Sources ===
|
||||
|
||||
@PostMapping("/propagation-sources")
|
||||
@Operation(summary = "Record a propagation source (seed/cutting receipt)")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<PropagationSource> recordPropagationSource(@Valid @RequestBody CreatePropagationSourceRequest request) {
|
||||
PropagationSource record = new PropagationSource();
|
||||
record.setClubId(request.clubId());
|
||||
record.setSourceType(request.sourceType());
|
||||
record.setSupplier(request.supplier());
|
||||
record.setQuantity(request.quantity());
|
||||
record.setStrainId(request.strainId());
|
||||
record.setReceivedAt(request.receivedAt());
|
||||
record.setDocumentationReference(request.documentationReference());
|
||||
record.setRecordedBy(request.recordedBy());
|
||||
|
||||
return ResponseEntity.ok(propagationSourceRepository.save(record));
|
||||
}
|
||||
|
||||
@GetMapping("/propagation-sources")
|
||||
@Operation(summary = "List propagation sources for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<PropagationSource>> listPropagationSources() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(propagationSourceRepository.findByTenantIdOrderByReceivedAtDesc(tenantId));
|
||||
}
|
||||
|
||||
// === Prevention Activities ===
|
||||
|
||||
@PostMapping("/prevention-activities")
|
||||
@Operation(summary = "Record a prevention/education activity per KCanG §23")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).MANAGE_COMPLIANCE)")
|
||||
public ResponseEntity<PreventionActivity> recordPreventionActivity(@Valid @RequestBody CreatePreventionActivityRequest request) {
|
||||
PreventionActivity record = new PreventionActivity();
|
||||
record.setClubId(request.clubId());
|
||||
record.setActivityDate(request.activityDate());
|
||||
record.setTitle(request.title());
|
||||
record.setDescription(request.description());
|
||||
record.setParticipantsCount(request.participantsCount());
|
||||
record.setOfficerId(request.officerId());
|
||||
|
||||
return ResponseEntity.ok(preventionActivityRepository.save(record));
|
||||
}
|
||||
|
||||
@GetMapping("/prevention-activities")
|
||||
@Operation(summary = "List prevention activities for the current tenant")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE)")
|
||||
public ResponseEntity<List<PreventionActivity>> listPreventionActivities() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(preventionActivityRepository.findByTenantIdOrderByActivityDateDesc(tenantId));
|
||||
}
|
||||
|
||||
// === Request DTOs (inner records) ===
|
||||
|
||||
public record CreateDestructionRequest(
|
||||
UUID clubId,
|
||||
UUID batchId,
|
||||
BigDecimal amountGrams,
|
||||
DestructionMethod destructionMethod,
|
||||
String description,
|
||||
Instant destroyedAt,
|
||||
UUID witnessedBy,
|
||||
String witnessName,
|
||||
UUID recordedBy
|
||||
) {}
|
||||
|
||||
public record CreateTransportRequest(
|
||||
UUID clubId,
|
||||
String description,
|
||||
LocalDate transportDate,
|
||||
String fromLocation,
|
||||
String toLocation,
|
||||
String carrierName,
|
||||
BigDecimal amountGrams,
|
||||
UUID batchId,
|
||||
TransportStatus status,
|
||||
UUID recordedBy
|
||||
) {}
|
||||
|
||||
public record CreatePropagationSourceRequest(
|
||||
UUID clubId,
|
||||
String sourceType,
|
||||
String supplier,
|
||||
Integer quantity,
|
||||
UUID strainId,
|
||||
LocalDate receivedAt,
|
||||
String documentationReference,
|
||||
UUID recordedBy
|
||||
) {}
|
||||
|
||||
public record CreatePreventionActivityRequest(
|
||||
UUID clubId,
|
||||
LocalDate activityDate,
|
||||
String title,
|
||||
String description,
|
||||
Integer participantsCount,
|
||||
UUID officerId
|
||||
) {}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import de.cannamanage.service.repository.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
@@ -43,7 +44,7 @@ public class ConsentController {
|
||||
@PostMapping
|
||||
@Operation(summary = "Grant consent")
|
||||
public ResponseEntity<ConsentResponse> grantConsent(
|
||||
@RequestBody GrantConsentRequest request,
|
||||
@Valid @RequestBody GrantConsentRequest request,
|
||||
Authentication auth,
|
||||
HttpServletRequest httpRequest) {
|
||||
UUID userId = resolveUserId(auth);
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.notification.RegisterDeviceRequest;
|
||||
import de.cannamanage.domain.entity.DeviceToken;
|
||||
import de.cannamanage.service.DeviceRegistrationService;
|
||||
import de.cannamanage.service.push.WebPushSender;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Device token registration endpoints for push notifications.
|
||||
* Any authenticated user can register/unregister their devices.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications/devices")
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceRegistrationController {
|
||||
|
||||
private final DeviceRegistrationService deviceRegistrationService;
|
||||
private final WebPushSender webPushSender;
|
||||
|
||||
/**
|
||||
* Register a device token for push notifications.
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> registerDevice(
|
||||
@Valid @RequestBody RegisterDeviceRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
|
||||
try {
|
||||
DeviceToken device = deviceRegistrationService.registerDevice(
|
||||
userId, request.platform(), request.token(), request.deviceName());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"id", device.getId(),
|
||||
"platform", device.getPlatform().name(),
|
||||
"deviceName", device.getDeviceName() != null ? device.getDeviceName() : "",
|
||||
"createdAt", device.getCreatedAt().toString()
|
||||
));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's registered devices.
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<?> listDevices(@AuthenticationPrincipal UserDetails user) {
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
var devices = deviceRegistrationService.getDevices(userId);
|
||||
|
||||
var items = devices.stream().map(d -> Map.of(
|
||||
"id", (Object) d.getId(),
|
||||
"platform", d.getPlatform().name(),
|
||||
"deviceName", d.getDeviceName() != null ? d.getDeviceName() : "",
|
||||
"lastUsedAt", d.getLastUsedAt() != null ? d.getLastUsedAt().toString() : "",
|
||||
"createdAt", d.getCreatedAt().toString()
|
||||
)).toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of("devices", items));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a device.
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> unregisterDevice(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
deviceRegistrationService.unregisterDevice(id, userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VAPID public key for Web Push subscription on the frontend.
|
||||
*/
|
||||
@GetMapping("/vapid-key")
|
||||
public ResponseEntity<Map<String, String>> getVapidKey() {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"publicKey", webPushSender.getPublicKey(),
|
||||
"configured", String.valueOf(webPushSender.isConfigured())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.Document;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import de.cannamanage.service.DocumentService;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
|
||||
public DocumentController(DocumentService documentService) {
|
||||
this.documentService = documentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the requested document belongs to the caller's current tenant (club).
|
||||
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
|
||||
* just by guessing or enumerating the document UUID.
|
||||
*/
|
||||
private Document loadOwnedDocument(UUID documentId) {
|
||||
Document doc = documentService.getDocument(documentId);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
|
||||
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@PostMapping("/documents/upload")
|
||||
public ResponseEntity<Document> uploadDocument(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam String title,
|
||||
@RequestParam DocumentCategory category,
|
||||
@RequestParam(defaultValue = "ALL_MEMBERS") DocumentAccessLevel accessLevel,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
Principal principal) throws IOException {
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
Document doc = documentService.uploadDocument(clubId, title, category, accessLevel, description, file, userId);
|
||||
return ResponseEntity.ok(doc);
|
||||
}
|
||||
|
||||
@GetMapping("/documents")
|
||||
public ResponseEntity<List<Document>> listDocuments(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam(required = false) DocumentCategory category,
|
||||
@RequestParam(required = false) DocumentAccessLevel accessLevel) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, category, accessLevel);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
|
||||
@GetMapping("/documents/{id}/download")
|
||||
public ResponseEntity<byte[]> downloadDocument(@PathVariable UUID id) throws IOException {
|
||||
Document doc = loadOwnedDocument(id);
|
||||
byte[] content = documentService.downloadDocument(id);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + doc.getFilename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(doc.getContentType()))
|
||||
.body(content);
|
||||
}
|
||||
|
||||
@DeleteMapping("/documents/{id}")
|
||||
public ResponseEntity<Void> deleteDocument(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID clubId,
|
||||
Principal principal) throws IOException {
|
||||
// Verify the document belongs to the caller's tenant before honouring the delete.
|
||||
// Also reject if the supplied clubId param disagrees with the authenticated tenant.
|
||||
Document doc = loadOwnedDocument(id);
|
||||
UUID currentTenantId = TenantContext.getCurrentTenant();
|
||||
if (!clubId.equals(currentTenantId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
|
||||
}
|
||||
UUID userId = UUID.fromString(principal.getName());
|
||||
documentService.deleteDocument(id, userId, doc.getClubId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/documents/usage")
|
||||
public ResponseEntity<Map<String, Long>> getStorageUsage(@RequestParam UUID clubId) {
|
||||
long usage = documentService.getStorageUsage(clubId);
|
||||
return ResponseEntity.ok(Map.of("bytesUsed", usage));
|
||||
}
|
||||
|
||||
// Portal endpoint — only ALL_MEMBERS documents
|
||||
@GetMapping("/portal/documents")
|
||||
public ResponseEntity<List<Document>> getPortalDocuments(@RequestParam UUID clubId) {
|
||||
List<Document> docs = documentService.listDocuments(clubId, null, DocumentAccessLevel.ALL_MEMBERS);
|
||||
return ResponseEntity.ok(docs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.event.*;
|
||||
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||
import de.cannamanage.domain.entity.ClubEvent;
|
||||
import de.cannamanage.domain.entity.EventRsvp;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.RsvpStatus;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.EventService;
|
||||
import de.cannamanage.service.repository.EventRsvpRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for club event management.
|
||||
* Admin endpoints require MANAGE_INFO_BOARD permission.
|
||||
* Portal endpoints are accessible to authenticated members.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class EventController {
|
||||
|
||||
private final EventService eventService;
|
||||
private final EventRsvpRepository rsvpRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final StaffPermissionChecker permissionChecker;
|
||||
|
||||
public EventController(EventService eventService,
|
||||
EventRsvpRepository rsvpRepository,
|
||||
MemberRepository memberRepository,
|
||||
StaffPermissionChecker permissionChecker) {
|
||||
this.eventService = eventService;
|
||||
this.rsvpRepository = rsvpRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.permissionChecker = permissionChecker;
|
||||
}
|
||||
|
||||
// === Admin endpoints ===
|
||||
|
||||
@PostMapping("/events")
|
||||
public ResponseEntity<EventResponse> createEvent(@Valid @RequestBody CreateEventRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
boolean postToInfoBoard = request.postToInfoBoard() == null || request.postToInfoBoard();
|
||||
|
||||
ClubEvent event = eventService.createEvent(
|
||||
clubId, request.title(), request.description(), request.eventType(),
|
||||
request.startAt(), request.endAt(), request.location(), request.maxAttendees(),
|
||||
request.recurring(), request.recurrenceRule(), request.recurrenceEndDate(),
|
||||
userId, postToInfoBoard
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(event, null));
|
||||
}
|
||||
|
||||
@GetMapping("/events")
|
||||
public ResponseEntity<List<EventResponse>> listEvents(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam Instant to,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
List<ClubEvent> events = eventService.listEvents(from, to);
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID memberId = getMemberIdForUser(userId);
|
||||
List<EventResponse> responses = events.stream()
|
||||
.map(e -> toResponse(e, memberId))
|
||||
.toList();
|
||||
return ResponseEntity.ok(responses);
|
||||
}
|
||||
|
||||
@GetMapping("/events/{id}")
|
||||
public ResponseEntity<EventResponse> getEvent(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
ClubEvent event = eventService.getEvent(id);
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID memberId = getMemberIdForUser(userId);
|
||||
return ResponseEntity.ok(toResponse(event, memberId));
|
||||
}
|
||||
|
||||
@PutMapping("/events/{id}")
|
||||
public ResponseEntity<EventResponse> updateEvent(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateEventRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
|
||||
ClubEvent event = eventService.updateEvent(id, request.title(), request.description(),
|
||||
request.eventType(), request.startAt(), request.endAt(), request.location(),
|
||||
request.maxAttendees(), request.recurring(), request.recurrenceRule(),
|
||||
request.recurrenceEndDate());
|
||||
return ResponseEntity.ok(toResponse(event, null));
|
||||
}
|
||||
|
||||
@DeleteMapping("/events/{id}")
|
||||
public ResponseEntity<Void> cancelEvent(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_INFO_BOARD);
|
||||
eventService.cancelEvent(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/events/{id}/rsvp")
|
||||
public ResponseEntity<?> rsvp(@PathVariable UUID id,
|
||||
@Valid @RequestBody RsvpRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID memberId = getMemberIdForUser(userId);
|
||||
if (memberId == null) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
try {
|
||||
EventRsvp rsvp = eventService.rsvp(id, memberId, request.status());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", rsvp.getStatus(),
|
||||
"respondedAt", rsvp.getRespondedAt()
|
||||
));
|
||||
} catch (IllegalStateException e) {
|
||||
if ("EVENT_FULL".equals(e.getMessage())) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "EVENT_FULL", "message", "Veranstaltung ist ausgebucht"));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/events/{id}/attendees")
|
||||
public ResponseEntity<List<RsvpResponse>> getAttendees(@PathVariable UUID id) {
|
||||
List<EventRsvp> rsvps = eventService.getAttendees(id);
|
||||
List<RsvpResponse> responses = rsvps.stream()
|
||||
.map(r -> {
|
||||
String memberName = memberRepository.findById(r.getMemberId())
|
||||
.map(m -> m.getFirstName() + " " + m.getLastName())
|
||||
.orElse("Unknown");
|
||||
return new RsvpResponse(r.getMemberId(), memberName, r.getStatus(), r.getRespondedAt());
|
||||
})
|
||||
.toList();
|
||||
return ResponseEntity.ok(responses);
|
||||
}
|
||||
|
||||
@GetMapping("/events/{id}/ical")
|
||||
public ResponseEntity<String> downloadIcal(@PathVariable UUID id) {
|
||||
String ical = eventService.generateIcal(id);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"event.ics\"")
|
||||
.contentType(MediaType.parseMediaType("text/calendar"))
|
||||
.body(ical);
|
||||
}
|
||||
|
||||
// === Portal endpoints ===
|
||||
|
||||
@GetMapping("/portal/events")
|
||||
public ResponseEntity<List<EventResponse>> portalEvents(@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID memberId = getMemberIdForUser(userId);
|
||||
List<ClubEvent> events = eventService.listUpcomingEvents(10);
|
||||
List<EventResponse> responses = events.stream()
|
||||
.map(e -> toResponse(e, memberId))
|
||||
.toList();
|
||||
return ResponseEntity.ok(responses);
|
||||
}
|
||||
|
||||
@PostMapping("/portal/events/{id}/rsvp")
|
||||
public ResponseEntity<?> portalRsvp(@PathVariable UUID id,
|
||||
@Valid @RequestBody RsvpRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
return rsvp(id, request, principal);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private EventResponse toResponse(ClubEvent event, UUID memberId) {
|
||||
Map<RsvpStatus, Long> counts = new HashMap<>();
|
||||
RsvpStatus myStatus = null;
|
||||
|
||||
if (event.getId() != null) {
|
||||
try {
|
||||
counts = eventService.getAttendeeCounts(event.getId());
|
||||
if (memberId != null) {
|
||||
myStatus = rsvpRepository.findByEventIdAndMemberId(event.getId(), memberId)
|
||||
.map(EventRsvp::getStatus)
|
||||
.orElse(null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Virtual expanded events may not have a DB id
|
||||
}
|
||||
}
|
||||
|
||||
return new EventResponse(
|
||||
event.getId(),
|
||||
event.getTitle(),
|
||||
event.getDescription(),
|
||||
event.getEventType(),
|
||||
event.getStartAt(),
|
||||
event.getEndAt(),
|
||||
event.getLocation(),
|
||||
event.getMaxAttendees(),
|
||||
event.isRecurring(),
|
||||
event.getRecurrenceRule(),
|
||||
event.getRecurrenceEndDate(),
|
||||
event.getCreatedBy(),
|
||||
event.getCreatedAt(),
|
||||
counts,
|
||||
myStatus
|
||||
);
|
||||
}
|
||||
|
||||
private UUID getMemberIdForUser(UUID userId) {
|
||||
return memberRepository.findByUserId(userId)
|
||||
.map(m -> m.getId())
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.finance.*;
|
||||
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.PaymentStatus;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.FinanceService;
|
||||
import de.cannamanage.service.FinancialReportService;
|
||||
import de.cannamanage.service.ReceiptPdfService;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.PaymentRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for club treasury management.
|
||||
* Admin endpoints require MANAGE_FINANCES or VIEW_FINANCES permission.
|
||||
* Portal endpoints allow members to view their own payment history and balance.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class FinanceController {
|
||||
|
||||
private final FinanceService financeService;
|
||||
private final StaffPermissionChecker permissionChecker;
|
||||
private final MemberRepository memberRepository;
|
||||
private final ReceiptPdfService receiptPdfService;
|
||||
private final FinancialReportService financialReportService;
|
||||
private final ClubRepository clubRepository;
|
||||
|
||||
public FinanceController(FinanceService financeService,
|
||||
StaffPermissionChecker permissionChecker,
|
||||
MemberRepository memberRepository,
|
||||
ReceiptPdfService receiptPdfService,
|
||||
FinancialReportService financialReportService,
|
||||
ClubRepository clubRepository) {
|
||||
this.financeService = financeService;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.memberRepository = memberRepository;
|
||||
this.receiptPdfService = receiptPdfService;
|
||||
this.financialReportService = financialReportService;
|
||||
this.clubRepository = clubRepository;
|
||||
}
|
||||
|
||||
// === Fee Schedules ===
|
||||
|
||||
@PostMapping("/finance/fee-schedules")
|
||||
public ResponseEntity<FeeSchedule> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
FeeSchedule schedule = financeService.createFeeSchedule(
|
||||
clubId, request.name(), request.amountCents(), request.interval(),
|
||||
request.isDefault() != null && request.isDefault()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(schedule);
|
||||
}
|
||||
|
||||
@GetMapping("/finance/fee-schedules")
|
||||
public ResponseEntity<List<FeeSchedule>> listFeeSchedules(@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(financeService.getActiveFeeSchedules(clubId));
|
||||
}
|
||||
|
||||
@PutMapping("/finance/fee-schedules/{id}")
|
||||
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateFeeScheduleRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
FeeSchedule updated = financeService.updateFeeSchedule(
|
||||
id, request.name(), request.amountCents(), request.interval(), request.isDefault()
|
||||
);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("/finance/fee-schedules/{id}/deactivate")
|
||||
public ResponseEntity<Void> deactivateFeeSchedule(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
financeService.deactivateFeeSchedule(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// === Fee Assignment ===
|
||||
|
||||
@PostMapping("/finance/members/{memberId}/assign-fee")
|
||||
public ResponseEntity<MemberFeeAssignment> assignFeeSchedule(@PathVariable UUID memberId,
|
||||
@Valid @RequestBody AssignFeeRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
MemberFeeAssignment assignment = financeService.assignFeeSchedule(
|
||||
memberId, clubId, request.feeScheduleId(), request.validFrom()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(assignment);
|
||||
}
|
||||
|
||||
// === Payments ===
|
||||
|
||||
@PostMapping("/finance/payments")
|
||||
public ResponseEntity<Payment> recordPayment(@Valid @RequestBody RecordPaymentRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
Payment payment = financeService.recordPayment(
|
||||
clubId, request.memberId(), request.amountCents(), request.paymentMethod(),
|
||||
request.periodFrom(), request.periodTo(), request.reference(), request.notes(), userId
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
|
||||
}
|
||||
|
||||
@GetMapping("/finance/payments")
|
||||
public ResponseEntity<Page<Payment>> listPayments(
|
||||
@RequestParam(required = false) UUID memberId,
|
||||
@RequestParam(required = false) PaymentStatus status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
|
||||
Page<Payment> result;
|
||||
if (memberId != null) {
|
||||
result = financeService.getPaymentsByMember(clubId, memberId, pageable);
|
||||
} else if (status != null) {
|
||||
result = financeService.getPaymentsByStatus(clubId, status, pageable);
|
||||
} else {
|
||||
result = financeService.getPayments(clubId, pageable);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PostMapping("/finance/payments/{id}/void")
|
||||
public ResponseEntity<Payment> voidPayment(@PathVariable UUID id,
|
||||
@Valid @RequestBody VoidPaymentRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
Payment voided = financeService.voidPayment(id, userId, request.reason());
|
||||
return ResponseEntity.ok(voided);
|
||||
}
|
||||
|
||||
// === Expenses ===
|
||||
|
||||
@PostMapping("/finance/expenses")
|
||||
public ResponseEntity<LedgerEntry> recordExpense(@Valid @RequestBody RecordExpenseRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
LedgerEntry entry = financeService.recordExpense(
|
||||
clubId, request.category(), request.amountCents(),
|
||||
request.description(), request.reference(), userId, request.transactionDate()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(entry);
|
||||
}
|
||||
|
||||
// === Ledger / Kassenbuch ===
|
||||
|
||||
@GetMapping("/finance/ledger")
|
||||
public ResponseEntity<Page<LedgerEntry>> getLedger(
|
||||
@RequestParam LocalDate from,
|
||||
@RequestParam LocalDate to,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "transactionDate"));
|
||||
|
||||
return ResponseEntity.ok(financeService.getLedgerEntries(clubId, from, to, pageable));
|
||||
}
|
||||
|
||||
// === Financial Summary ===
|
||||
|
||||
@GetMapping("/finance/summary")
|
||||
public ResponseEntity<Map<String, Object>> getFinancialSummary(
|
||||
@RequestParam LocalDate from,
|
||||
@RequestParam LocalDate to,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getFinancialSummary(clubId, from, to));
|
||||
}
|
||||
|
||||
// === Outstanding ===
|
||||
|
||||
@GetMapping("/finance/outstanding")
|
||||
public ResponseEntity<List<Map<String, Object>>> getOutstandingMembers(
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getOutstandingMembers(clubId));
|
||||
}
|
||||
|
||||
// === Member Balance (Admin) ===
|
||||
|
||||
@GetMapping("/finance/members/{memberId}/balance")
|
||||
public ResponseEntity<Map<String, Object>> getMemberBalance(@PathVariable UUID memberId,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
||||
}
|
||||
|
||||
// === Portal Endpoints (member self-service) ===
|
||||
|
||||
@GetMapping("/portal/finance/payments")
|
||||
public ResponseEntity<Page<Payment>> getMyPayments(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return ResponseEntity.ok(financeService.getPaymentsByMember(clubId, memberId, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/portal/finance/balance")
|
||||
public ResponseEntity<Map<String, Object>> getMyBalance(@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||
|
||||
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
||||
}
|
||||
|
||||
// === Receipt PDF Download ===
|
||||
|
||||
@GetMapping("/finance/payments/{id}/receipt")
|
||||
public ResponseEntity<byte[]> downloadReceipt(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
Payment payment = financeService.getPaymentById(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
|
||||
Member member = memberRepository.findById(payment.getMemberId())
|
||||
.orElseThrow(() -> new NoSuchElementException("Member not found"));
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||
|
||||
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||
String filename = "Quittung-" + (payment.getReference() != null
|
||||
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.contentLength(pdf.length)
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
// === Annual Report PDF ===
|
||||
|
||||
@GetMapping("/finance/reports/annual")
|
||||
public ResponseEntity<byte[]> downloadAnnualReport(@RequestParam int year,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||
|
||||
FinancialReportService.AnnualReportData reportData = financeService.buildAnnualReportData(clubId, year);
|
||||
byte[] pdf = financialReportService.generateAnnualReport(reportData, club);
|
||||
String filename = "Jahresabschluss-" + year + "-" + club.getName().replaceAll("\\s+", "_") + ".pdf";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.contentLength(pdf.length)
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
// === Kassenbuch CSV Export ===
|
||||
|
||||
@GetMapping("/finance/ledger/export")
|
||||
public ResponseEntity<byte[]> exportLedgerCsv(@RequestParam LocalDate from,
|
||||
@RequestParam LocalDate to,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
byte[] csv = financeService.exportLedgerCsv(clubId, from, to);
|
||||
String filename = "Kassenbuch-" + from + "-" + to + ".csv";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv; charset=ISO-8859-1"))
|
||||
.contentLength(csv.length)
|
||||
.body(csv);
|
||||
}
|
||||
|
||||
// === Portal: Receipt download (own payments only) ===
|
||||
|
||||
@GetMapping("/portal/finance/payments/{id}/receipt")
|
||||
public ResponseEntity<byte[]> downloadMyReceipt(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||
|
||||
Payment payment = financeService.getPaymentById(id)
|
||||
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + id));
|
||||
|
||||
// Verify payment belongs to the requesting member
|
||||
if (!payment.getMemberId().equals(memberId)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
Member member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Member not found"));
|
||||
Club club = clubRepository.findById(clubId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Club not found"));
|
||||
|
||||
byte[] pdf = receiptPdfService.generateReceipt(payment, member, club);
|
||||
String filename = "Quittung-" + (payment.getReference() != null
|
||||
? payment.getReference() : id.toString().substring(0, 8)) + ".pdf";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.contentLength(pdf.length)
|
||||
.body(pdf);
|
||||
}
|
||||
|
||||
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
|
||||
return memberRepository.findByUserId(userId)
|
||||
.map(Member::getId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Member not found for user: " + userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.*;
|
||||
import de.cannamanage.service.ForumService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Forum controller — admin and portal endpoints for forum topics, replies, reactions, and reports.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class ForumController {
|
||||
|
||||
private final ForumService forumService;
|
||||
|
||||
public ForumController(ForumService forumService) {
|
||||
this.forumService = forumService;
|
||||
}
|
||||
|
||||
// ---- Admin Topic Endpoints ----
|
||||
|
||||
@PostMapping("/forum/topics")
|
||||
public ResponseEntity<ForumTopic> createTopic(@Valid @RequestBody CreateTopicRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumTopic topic = forumService.createTopic(clubId, request.title(), request.content(), userId);
|
||||
return ResponseEntity.ok(topic);
|
||||
}
|
||||
|
||||
@GetMapping("/forum/topics")
|
||||
public ResponseEntity<Page<ForumTopic>> getTopics(@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/forum/topics/{id}")
|
||||
public ResponseEntity<ForumTopic> getTopic(@PathVariable UUID id) {
|
||||
return forumService.getTopic(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/forum/topics/{id}/lock")
|
||||
public ResponseEntity<ForumTopic> lockTopic(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.lockTopic(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/forum/topics/{id}/unlock")
|
||||
public ResponseEntity<ForumTopic> unlockTopic(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.unlockTopic(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/forum/topics/{id}/pin")
|
||||
public ResponseEntity<ForumTopic> pinTopic(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.pinTopic(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/forum/topics/{id}/unpin")
|
||||
public ResponseEntity<ForumTopic> unpinTopic(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.unpinTopic(id, userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/forum/topics/{id}")
|
||||
public ResponseEntity<Void> deleteTopic(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
forumService.deleteTopic(id, userId, reason);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ---- Reply Endpoints ----
|
||||
|
||||
@GetMapping("/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<Page<ForumReply>> getReplies(@PathVariable UUID topicId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
|
||||
}
|
||||
|
||||
@PostMapping("/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<ForumReply> createReply(@PathVariable UUID topicId,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReply reply = forumService.createReply(topicId, request.content(), userId);
|
||||
return ResponseEntity.ok(reply);
|
||||
}
|
||||
|
||||
@PutMapping("/forum/replies/{id}")
|
||||
public ResponseEntity<ForumReply> editReply(@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReply reply = forumService.editReply(id, request.content(), userId);
|
||||
return ResponseEntity.ok(reply);
|
||||
}
|
||||
|
||||
@DeleteMapping("/forum/replies/{id}")
|
||||
public ResponseEntity<Void> deleteReply(@PathVariable UUID id,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
forumService.deleteReply(id, userId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ---- Reaction Endpoints ----
|
||||
|
||||
@PostMapping("/forum/reactions")
|
||||
public ResponseEntity<Map<String, Object>> toggleReaction(@Valid @RequestBody ReactionRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
var result = forumService.toggleReaction(
|
||||
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||
boolean active = result.isPresent();
|
||||
return ResponseEntity.ok(Map.of("active", active, "reactionType", request.reactionType().name()));
|
||||
}
|
||||
|
||||
// ---- Report Endpoints ----
|
||||
|
||||
@PostMapping("/forum/reports")
|
||||
public ResponseEntity<Map<String, String>> reportContent(@Valid @RequestBody ReportRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||
return ResponseEntity.ok(Map.of("status", "reported"));
|
||||
}
|
||||
|
||||
@GetMapping("/forum/reports")
|
||||
public ResponseEntity<Page<ForumReport>> getReports(@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestParam(defaultValue = "OPEN") ReportStatus status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(forumService.getReports(clubId, status, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/forum/reports/count")
|
||||
public ResponseEntity<Map<String, Long>> getOpenReportCount(@RequestHeader("X-Club-Id") UUID clubId) {
|
||||
return ResponseEntity.ok(Map.of("count", forumService.getOpenReportCount(clubId)));
|
||||
}
|
||||
|
||||
@PostMapping("/forum/reports/{id}/review")
|
||||
public ResponseEntity<ForumReport> reviewReport(@PathVariable UUID id,
|
||||
@Valid @RequestBody ReviewReportRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
ForumReport report = forumService.reviewReport(id, userId, request.status());
|
||||
return ResponseEntity.ok(report);
|
||||
}
|
||||
|
||||
// ---- Portal Endpoints (member-scoped, same logic) ----
|
||||
|
||||
@PostMapping("/portal/forum/topics")
|
||||
public ResponseEntity<ForumTopic> portalCreateTopic(@Valid @RequestBody CreateTopicRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.createTopic(clubId, request.title(), request.content(), userId));
|
||||
}
|
||||
|
||||
@GetMapping("/portal/forum/topics")
|
||||
public ResponseEntity<Page<ForumTopic>> portalGetTopics(@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(forumService.getTopics(clubId, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/portal/forum/topics/{id}")
|
||||
public ResponseEntity<ForumTopic> portalGetTopic(@PathVariable UUID id) {
|
||||
return forumService.getTopic(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/portal/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<Page<ForumReply>> portalGetReplies(@PathVariable UUID topicId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
return ResponseEntity.ok(forumService.getReplies(topicId, page, size));
|
||||
}
|
||||
|
||||
@PostMapping("/portal/forum/topics/{topicId}/replies")
|
||||
public ResponseEntity<ForumReply> portalCreateReply(@PathVariable UUID topicId,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.createReply(topicId, request.content(), userId));
|
||||
}
|
||||
|
||||
@PutMapping("/portal/forum/replies/{id}")
|
||||
public ResponseEntity<ForumReply> portalEditReply(@PathVariable UUID id,
|
||||
@Valid @RequestBody CreateReplyRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
return ResponseEntity.ok(forumService.editReply(id, request.content(), userId));
|
||||
}
|
||||
|
||||
@PostMapping("/portal/forum/reactions")
|
||||
public ResponseEntity<Map<String, Object>> portalToggleReaction(@Valid @RequestBody ReactionRequest request,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
var result = forumService.toggleReaction(
|
||||
request.targetType(), request.targetId(), userId, request.reactionType());
|
||||
return ResponseEntity.ok(Map.of("active", result.isPresent(), "reactionType", request.reactionType().name()));
|
||||
}
|
||||
|
||||
@PostMapping("/portal/forum/reports")
|
||||
public ResponseEntity<Map<String, String>> portalReportContent(@Valid @RequestBody ReportRequest request,
|
||||
@RequestHeader("X-Club-Id") UUID clubId,
|
||||
@RequestHeader("X-User-Id") UUID userId) {
|
||||
forumService.reportContent(clubId, request.targetType(), request.targetId(), userId, request.reason());
|
||||
return ResponseEntity.ok(Map.of("status", "reported"));
|
||||
}
|
||||
|
||||
// ---- Request Records ----
|
||||
|
||||
public record CreateTopicRequest(String title, String content) {}
|
||||
public record CreateReplyRequest(String content) {}
|
||||
public record ReactionRequest(ForumTargetType targetType, UUID targetId, ReactionType reactionType) {}
|
||||
public record ReportRequest(ForumTargetType targetType, UUID targetId, String reason) {}
|
||||
public record ReviewReportRequest(ReportStatus status) {}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.InfoBoardPost;
|
||||
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||
import de.cannamanage.service.InfoBoardService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Info Board (Schwarzes Brett) endpoints for admin and portal.
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class InfoBoardController {
|
||||
|
||||
private final InfoBoardService infoBoardService;
|
||||
|
||||
// ============================================================
|
||||
// ADMIN ENDPOINTS (require MANAGE_INFO_BOARD permission)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a new info board post.
|
||||
*/
|
||||
@PostMapping("/api/v1/info-board")
|
||||
public ResponseEntity<?> createPost(
|
||||
@Valid @RequestBody CreatePostRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
|
||||
UUID authorId = UUID.fromString(user.getUsername());
|
||||
InfoBoardPost post = infoBoardService.createPost(
|
||||
request.clubId(), request.title(), request.content(),
|
||||
request.category(), request.pinned() != null && request.pinned(), authorId);
|
||||
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* List posts (admin view with optional filters).
|
||||
*/
|
||||
@GetMapping("/api/v1/info-board")
|
||||
public ResponseEntity<?> listPosts(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam(required = false) InfoBoardCategory category,
|
||||
@RequestParam(defaultValue = "false") boolean includeArchived,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
|
||||
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, includeArchived, page, size);
|
||||
var items = posts.getContent().stream().map(this::toResponse).toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"posts", items,
|
||||
"totalElements", posts.getTotalElements(),
|
||||
"totalPages", posts.getTotalPages(),
|
||||
"page", posts.getNumber()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single post.
|
||||
*/
|
||||
@GetMapping("/api/v1/info-board/{id}")
|
||||
public ResponseEntity<?> getPost(@PathVariable UUID id) {
|
||||
InfoBoardPost post = infoBoardService.getPost(id);
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a post.
|
||||
*/
|
||||
@PutMapping("/api/v1/info-board/{id}")
|
||||
public ResponseEntity<?> updatePost(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdatePostRequest request) {
|
||||
|
||||
InfoBoardPost post = infoBoardService.updatePost(
|
||||
id, request.title(), request.content(), request.category(), request.pinned());
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post.
|
||||
*/
|
||||
@DeleteMapping("/api/v1/info-board/{id}")
|
||||
public ResponseEntity<?> deletePost(@PathVariable UUID id) {
|
||||
infoBoardService.deletePost(id);
|
||||
return ResponseEntity.ok(Map.of("deleted", true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a post.
|
||||
*/
|
||||
@PostMapping("/api/v1/info-board/{id}/archive")
|
||||
public ResponseEntity<?> archivePost(@PathVariable UUID id) {
|
||||
InfoBoardPost post = infoBoardService.archivePost(id);
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive a post.
|
||||
*/
|
||||
@PostMapping("/api/v1/info-board/{id}/unarchive")
|
||||
public ResponseEntity<?> unarchivePost(@PathVariable UUID id) {
|
||||
InfoBoardPost post = infoBoardService.unarchivePost(id);
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pin status.
|
||||
*/
|
||||
@PostMapping("/api/v1/info-board/{id}/pin")
|
||||
public ResponseEntity<?> togglePin(@PathVariable UUID id) {
|
||||
InfoBoardPost post = infoBoardService.togglePin(id);
|
||||
return ResponseEntity.ok(toResponse(post));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PORTAL ENDPOINTS (member access)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get posts for the member's club (non-archived, pinned first).
|
||||
*/
|
||||
@GetMapping("/api/v1/portal/info-board")
|
||||
public ResponseEntity<?> getPortalPosts(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam(required = false) InfoBoardCategory category,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
|
||||
Page<InfoBoardPost> posts = infoBoardService.getPosts(clubId, category, false, page, size);
|
||||
var items = posts.getContent().stream().map(this::toResponse).toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"posts", items,
|
||||
"totalElements", posts.getTotalElements(),
|
||||
"totalPages", posts.getTotalPages(),
|
||||
"page", posts.getNumber()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a post as read.
|
||||
*/
|
||||
@PostMapping("/api/v1/portal/info-board/{id}/read")
|
||||
public ResponseEntity<?> markAsRead(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam UUID memberId) {
|
||||
infoBoardService.markAsRead(id, memberId);
|
||||
return ResponseEntity.ok(Map.of("read", true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread post count for badge display.
|
||||
*/
|
||||
@GetMapping("/api/v1/portal/info-board/unread-count")
|
||||
public ResponseEntity<?> getUnreadCount(
|
||||
@RequestParam UUID clubId,
|
||||
@RequestParam UUID memberId) {
|
||||
long count = infoBoardService.getUnreadCount(clubId, memberId);
|
||||
return ResponseEntity.ok(Map.of("unreadCount", count));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DTOs
|
||||
// ============================================================
|
||||
|
||||
public record CreatePostRequest(
|
||||
@NotNull UUID clubId,
|
||||
@NotBlank @Size(max = 200) String title,
|
||||
@NotBlank String content,
|
||||
@NotNull InfoBoardCategory category,
|
||||
Boolean pinned
|
||||
) {}
|
||||
|
||||
public record UpdatePostRequest(
|
||||
@Size(max = 200) String title,
|
||||
String content,
|
||||
InfoBoardCategory category,
|
||||
Boolean pinned
|
||||
) {}
|
||||
|
||||
// ============================================================
|
||||
// Response mapping
|
||||
// ============================================================
|
||||
|
||||
private Map<String, Object> toResponse(InfoBoardPost post) {
|
||||
return Map.of(
|
||||
"id", post.getId(),
|
||||
"clubId", post.getClubId(),
|
||||
"title", post.getTitle(),
|
||||
"content", post.getContent(),
|
||||
"category", post.getCategory().name(),
|
||||
"pinned", post.isPinned(),
|
||||
"archived", post.isArchived(),
|
||||
"authorId", post.getAuthorId(),
|
||||
"createdAt", post.getCreatedAt().toString(),
|
||||
"updatedAt", post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : ""
|
||||
);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.service.CustomMailDomainService;
|
||||
import de.cannamanage.service.PlanTierService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for Enterprise custom email domain management.
|
||||
* All endpoints require ADMIN role + Enterprise tier.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/settings/mail")
|
||||
public class MailSettingsController {
|
||||
|
||||
private final CustomMailDomainService customMailDomainService;
|
||||
private final PlanTierService planTierService;
|
||||
|
||||
public MailSettingsController(CustomMailDomainService customMailDomainService,
|
||||
PlanTierService planTierService) {
|
||||
this.customMailDomainService = customMailDomainService;
|
||||
this.planTierService = planTierService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom FROM address for the club's outbound emails.
|
||||
* Enterprise tier only.
|
||||
*/
|
||||
@PostMapping("/custom-domain")
|
||||
public ResponseEntity<MailDomainStatusResponse> setCustomDomain(
|
||||
@Valid @RequestBody CustomMailDomainRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||
CustomMailDomain domain = customMailDomainService.setCustomDomain(tenantId, request.fromAddress());
|
||||
return ResponseEntity.ok(toResponse(domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current custom domain status.
|
||||
*/
|
||||
@GetMapping("/custom-domain")
|
||||
public ResponseEntity<MailDomainStatusResponse> getCustomDomain() {
|
||||
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||
planTierService.requireEnterpriseTier(tenantId);
|
||||
|
||||
return customMailDomainService.getCustomDomain(tenantId)
|
||||
.map(domain -> ResponseEntity.ok(toResponse(domain)))
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DNS verification for the custom domain.
|
||||
*/
|
||||
@PostMapping("/custom-domain/verify")
|
||||
public ResponseEntity<MailDomainStatusResponse> verifyCustomDomain() {
|
||||
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||
CustomMailDomain domain = customMailDomainService.verifyDomain(tenantId);
|
||||
return ResponseEntity.ok(toResponse(domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove custom domain configuration (revert to platform default).
|
||||
*/
|
||||
@DeleteMapping("/custom-domain")
|
||||
public ResponseEntity<Void> removeCustomDomain() {
|
||||
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||
planTierService.requireEnterpriseTier(tenantId);
|
||||
customMailDomainService.removeCustomDomain(tenantId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private MailDomainStatusResponse toResponse(CustomMailDomain domain) {
|
||||
return new MailDomainStatusResponse(
|
||||
domain.getFromAddress(),
|
||||
domain.getDomain(),
|
||||
domain.isVerified(),
|
||||
domain.getVerificationToken(),
|
||||
domain.getVerifiedAt() != null ? domain.getVerifiedAt().toString() : null,
|
||||
"cannamanage-verify=" + domain.getVerificationToken()
|
||||
);
|
||||
}
|
||||
|
||||
// --- DTOs ---
|
||||
|
||||
public record CustomMailDomainRequest(
|
||||
@NotBlank @Email String fromAddress
|
||||
) {}
|
||||
|
||||
public record MailDomainStatusResponse(
|
||||
String fromAddress,
|
||||
String domain,
|
||||
boolean verified,
|
||||
String verificationToken,
|
||||
String verifiedAt,
|
||||
String requiredDnsTxtRecord
|
||||
) {}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.notification.ComposeNotificationRequest;
|
||||
import de.cannamanage.domain.entity.NotificationSend;
|
||||
import de.cannamanage.domain.enums.TargetType;
|
||||
import de.cannamanage.service.NotificationService;
|
||||
import de.cannamanage.service.repository.NotificationSendRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Admin notification compose endpoints.
|
||||
* Requires SEND_NOTIFICATIONS permission (checked via StaffPermissionChecker).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationComposeController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final NotificationSendRepository notificationSendRepository;
|
||||
|
||||
/**
|
||||
* Compose and send a notification (broadcast or targeted).
|
||||
*/
|
||||
@PostMapping("/compose")
|
||||
public ResponseEntity<Map<String, Object>> composeAndSend(
|
||||
@Valid @RequestBody ComposeNotificationRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
|
||||
UUID authorId = UUID.fromString(user.getUsername());
|
||||
NotificationSend send;
|
||||
|
||||
if (request.targetType() == TargetType.ALL) {
|
||||
send = notificationService.sendBroadcast(
|
||||
request.title(), request.message(), request.link(), authorId);
|
||||
} else {
|
||||
if (request.recipientIds() == null || request.recipientIds().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "recipientIds required for SELECTED target type"));
|
||||
}
|
||||
send = notificationService.sendToSelected(
|
||||
request.title(), request.message(), request.link(), authorId, request.recipientIds());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"id", send.getId(),
|
||||
"targetType", send.getTargetType().name(),
|
||||
"targetCount", send.getTargetCount(),
|
||||
"sentAt", send.getSentAt().toString()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* List sent notifications (paginated).
|
||||
*/
|
||||
@GetMapping("/sends")
|
||||
public ResponseEntity<?> listSends(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
|
||||
var sends = notificationSendRepository.findAllByOrderBySentAtDesc(PageRequest.of(page, size));
|
||||
var items = sends.getContent().stream().map(s -> Map.of(
|
||||
"id", (Object) s.getId(),
|
||||
"title", s.getTitle(),
|
||||
"targetType", s.getTargetType().name(),
|
||||
"targetCount", s.getTargetCount(),
|
||||
"readCount", s.getReadCount(),
|
||||
"sentAt", s.getSentAt().toString()
|
||||
)).toList();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"sends", items,
|
||||
"totalElements", sends.getTotalElements(),
|
||||
"totalPages", sends.getTotalPages()
|
||||
));
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.notification.UpdatePreferencesRequest;
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
import de.cannamanage.service.NotificationPreferenceService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Notification preferences endpoints.
|
||||
* Any authenticated user can view/update their notification channel preferences.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications/preferences")
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationPreferenceController {
|
||||
|
||||
private final NotificationPreferenceService preferenceService;
|
||||
|
||||
/**
|
||||
* Get user's notification channel preferences.
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getPreferences(@AuthenticationPrincipal UserDetails user) {
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
var prefs = preferenceService.getOrCreatePreferences(userId);
|
||||
|
||||
var prefsMap = prefs.stream().collect(Collectors.toMap(
|
||||
p -> p.getChannel().name(),
|
||||
p -> (Object) p.isEnabled()
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(Map.of("preferences", prefsMap));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification channel preferences.
|
||||
* IN_APP cannot be disabled (server-side enforcement).
|
||||
*/
|
||||
@PutMapping
|
||||
public ResponseEntity<?> updatePreferences(
|
||||
@Valid @RequestBody UpdatePreferencesRequest request,
|
||||
@AuthenticationPrincipal UserDetails user) {
|
||||
|
||||
UUID userId = UUID.fromString(user.getUsername());
|
||||
|
||||
try {
|
||||
for (var entry : request.preferences().entrySet()) {
|
||||
preferenceService.updatePreference(userId, entry.getKey(), entry.getValue());
|
||||
}
|
||||
|
||||
// Return updated preferences
|
||||
var prefs = preferenceService.getOrCreatePreferences(userId);
|
||||
var prefsMap = prefs.stream().collect(Collectors.toMap(
|
||||
p -> p.getChannel().name(),
|
||||
p -> (Object) p.isEnabled()
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(Map.of("preferences", prefsMap));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,37 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.report.AuthorityExportRequest;
|
||||
import de.cannamanage.api.dto.report.MemberListResponse;
|
||||
import de.cannamanage.api.dto.report.MonthlyReportResponse;
|
||||
import de.cannamanage.api.dto.report.RecallReportResponse;
|
||||
import de.cannamanage.domain.entity.Club;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ExportFormat;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.domain.enums.ReportType;
|
||||
import de.cannamanage.service.CsvReportGenerator;
|
||||
import de.cannamanage.service.PdfReportGenerator;
|
||||
import de.cannamanage.service.ReportGeneratorService;
|
||||
import de.cannamanage.service.ReportService;
|
||||
import de.cannamanage.service.model.report.MemberListReport;
|
||||
import de.cannamanage.service.model.report.MonthlyReport;
|
||||
import de.cannamanage.service.model.report.RecallReport;
|
||||
import de.cannamanage.service.report.AuthorityExportService;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.time.YearMonth;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for compliance and operational reports.
|
||||
@@ -34,15 +45,50 @@ public class ReportController {
|
||||
private final PdfReportGenerator pdfGenerator;
|
||||
private final CsvReportGenerator csvGenerator;
|
||||
private final ClubRepository clubRepository;
|
||||
private final ReportGeneratorService reportGeneratorService;
|
||||
private final AuthorityExportService authorityExportService;
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public ReportController(ReportService reportService,
|
||||
PdfReportGenerator pdfGenerator,
|
||||
CsvReportGenerator csvGenerator,
|
||||
ClubRepository clubRepository) {
|
||||
ClubRepository clubRepository,
|
||||
ReportGeneratorService reportGeneratorService,
|
||||
AuthorityExportService authorityExportService,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.reportService = reportService;
|
||||
this.pdfGenerator = pdfGenerator;
|
||||
this.csvGenerator = csvGenerator;
|
||||
this.clubRepository = clubRepository;
|
||||
this.reportGeneratorService = reportGeneratorService;
|
||||
this.authorityExportService = authorityExportService;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available report types with their supported export formats.
|
||||
* GET /api/v1/reports/types
|
||||
*/
|
||||
@GetMapping("/types")
|
||||
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(#root, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
|
||||
public ResponseEntity<List<Map<String, Object>>> listReportTypes() {
|
||||
Map<ReportType, Set<ExportFormat>> availableTypes = reportGeneratorService.getAvailableTypes();
|
||||
|
||||
List<Map<String, Object>> response = new ArrayList<>();
|
||||
for (var entry : availableTypes.entrySet()) {
|
||||
Map<String, Object> typeInfo = new LinkedHashMap<>();
|
||||
typeInfo.put("type", entry.getKey().name());
|
||||
typeInfo.put("formats", entry.getValue().stream()
|
||||
.map(ExportFormat::name)
|
||||
.sorted()
|
||||
.toList());
|
||||
response.add(typeInfo);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +228,49 @@ public class ReportController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Authority Export (Behörden-Export) — THE HERO FEATURE.
|
||||
* Generates a streaming ZIP containing all compliance documents.
|
||||
* Requires re-authentication (password re-entry) + mandatory reason.
|
||||
* Rate limited: max 1 export per hour per tenant.
|
||||
*
|
||||
* POST /api/v1/reports/authority-export
|
||||
*/
|
||||
@PostMapping("/authority-export")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<StreamingResponseBody> authorityExport(
|
||||
@Valid @RequestBody AuthorityExportRequest request,
|
||||
@AuthenticationPrincipal UUID userId) {
|
||||
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
|
||||
// Rate limit check
|
||||
if (authorityExportService.isRateLimited(tenantId)) {
|
||||
return ResponseEntity.status(429)
|
||||
.header("Retry-After", "3600")
|
||||
.build();
|
||||
}
|
||||
|
||||
// Re-authentication: verify password against BCrypt hash
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalStateException("Authenticated user not found"));
|
||||
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
// Stream the ZIP
|
||||
StreamingResponseBody responseBody = outputStream ->
|
||||
authorityExportService.streamAuthorityExport(
|
||||
outputStream, tenantId, request.year(), userId, request.reason());
|
||||
|
||||
String filename = "Behoerden_Export_" + request.year() + "_" + tenantId.toString().substring(0, 8) + ".zip";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("application/zip"))
|
||||
.body(responseBody);
|
||||
}
|
||||
|
||||
private RecallReportResponse toRecallResponse(RecallReport r) {
|
||||
return new RecallReportResponse(
|
||||
r.getBatchId(),
|
||||
|
||||
@@ -83,7 +83,7 @@ public class StaffController {
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
|
||||
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
|
||||
@RequestBody UpdateStaffRequest request) {
|
||||
@Valid @RequestBody UpdateStaffRequest request) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
StaffAccount staff = staffService.updateStaff(
|
||||
tenantId, id,
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import de.cannamanage.service.StripeService;
|
||||
import de.cannamanage.service.repository.ClubRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -46,7 +47,7 @@ public class SubscriptionController {
|
||||
@PostMapping("/checkout")
|
||||
@Operation(summary = "Create checkout session", description = "Creates a Stripe Checkout session for plan upgrade")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody CheckoutRequest request) throws StripeException {
|
||||
public ResponseEntity<Map<String, String>> createCheckout(@Valid @RequestBody CheckoutRequest request) throws StripeException {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
UUID clubId = clubRepository.findByTenantId(tenantId)
|
||||
.orElseThrow(() -> new IllegalStateException("No club for tenant"))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}.
|
||||
* Used by the admin to manually attach a transaction to a member the matching engine missed.
|
||||
*/
|
||||
public record AssignRequest(
|
||||
@NotNull UUID memberId
|
||||
) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}.
|
||||
* Surfaces the number of transactions that were confirmed, skipped (low confidence /
|
||||
* already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback.
|
||||
*/
|
||||
public record BulkConfirmResponse(
|
||||
int confirmed,
|
||||
int skipped,
|
||||
int failed,
|
||||
int total
|
||||
) {
|
||||
public static BulkConfirmResponse from(BulkConfirmResult r) {
|
||||
return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}.
|
||||
* <p>
|
||||
* The {@code memberId} is required so the caller explicitly acknowledges which member receives
|
||||
* the payment, even when the matching engine had already pre-selected one.
|
||||
*/
|
||||
public record ConfirmRequest(
|
||||
@NotNull UUID memberId
|
||||
) {}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}.
|
||||
* Captures the column layout of a club-specific CSV bank export so future imports can
|
||||
* be parsed without re-mapping.
|
||||
*/
|
||||
public record CreateMappingRequest(
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
@Min(0) @Max(50) int dateColumn,
|
||||
@Min(0) @Max(50) int amountColumn,
|
||||
@Min(0) @Max(50) int referenceColumn,
|
||||
Integer counterpartyColumn,
|
||||
Integer ibanColumn,
|
||||
@Size(max = 4) String delimiter,
|
||||
@Size(max = 32) String dateFormat,
|
||||
@Size(max = 2) String decimalSeparator,
|
||||
@Min(0) @Max(20) Integer skipHeaderRows,
|
||||
@Size(max = 32) String encoding,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankImportSession;
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import de.cannamanage.domain.enums.ImportSessionStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views.
|
||||
* Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays).
|
||||
*/
|
||||
public record ImportSessionResponse(
|
||||
UUID id,
|
||||
UUID clubId,
|
||||
String filename,
|
||||
BankFormat format,
|
||||
ImportSessionStatus status,
|
||||
Integer totalTransactions,
|
||||
Integer matchedCount,
|
||||
Integer confirmedCount,
|
||||
Integer skippedCount,
|
||||
UUID uploadedBy,
|
||||
String errorMessage,
|
||||
Instant createdAt,
|
||||
Instant completedAt
|
||||
) {
|
||||
public static ImportSessionResponse from(BankImportSession s) {
|
||||
return new ImportSessionResponse(
|
||||
s.getId(),
|
||||
s.getClubId(),
|
||||
s.getFilename(),
|
||||
s.getFormat(),
|
||||
s.getStatus(),
|
||||
s.getTotalTransactions(),
|
||||
s.getMatchedCount(),
|
||||
s.getConfirmedCount(),
|
||||
s.getSkippedCount(),
|
||||
s.getUploadedBy(),
|
||||
s.getErrorMessage(),
|
||||
s.getCreatedAt(),
|
||||
s.getCompletedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}.
|
||||
* The {@code reason} field is free text shown in the audit log and review history.
|
||||
*/
|
||||
public record SkipRequest(
|
||||
String reason
|
||||
) {}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package de.cannamanage.api.dto.bankimport;
|
||||
|
||||
import de.cannamanage.domain.entity.BankTransaction;
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard.
|
||||
*/
|
||||
public record TransactionResponse(
|
||||
UUID id,
|
||||
UUID sessionId,
|
||||
LocalDate bookingDate,
|
||||
LocalDate valueDate,
|
||||
Integer amountCents,
|
||||
String currency,
|
||||
String referenceText,
|
||||
String counterpartyName,
|
||||
String counterpartyIban,
|
||||
String bankReference,
|
||||
MatchStatus matchStatus,
|
||||
Integer matchConfidence,
|
||||
UUID matchedMemberId,
|
||||
UUID matchedPaymentId,
|
||||
String skipReason
|
||||
) {
|
||||
public static TransactionResponse from(BankTransaction t) {
|
||||
return new TransactionResponse(
|
||||
t.getId(),
|
||||
t.getSessionId(),
|
||||
t.getBookingDate(),
|
||||
t.getValueDate(),
|
||||
t.getAmountCents(),
|
||||
t.getCurrency(),
|
||||
t.getReferenceText(),
|
||||
t.getCounterpartyName(),
|
||||
t.getCounterpartyIban(),
|
||||
t.getBankReference(),
|
||||
t.getMatchStatus(),
|
||||
t.getMatchConfidence(),
|
||||
t.getMatchedMemberId(),
|
||||
t.getMatchedPaymentId(),
|
||||
t.getSkipReason()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.cannamanage.api.dto.event;
|
||||
|
||||
import de.cannamanage.domain.enums.EventType;
|
||||
import de.cannamanage.domain.enums.RecurrenceRule;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateEventRequest(
|
||||
@NotBlank @Size(max = 200) String title,
|
||||
String description,
|
||||
@NotNull EventType eventType,
|
||||
@NotNull Instant startAt,
|
||||
Instant endAt,
|
||||
@Size(max = 300) String location,
|
||||
Integer maxAttendees,
|
||||
boolean recurring,
|
||||
RecurrenceRule recurrenceRule,
|
||||
LocalDate recurrenceEndDate,
|
||||
Boolean postToInfoBoard // defaults to true if null
|
||||
) {}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.cannamanage.api.dto.event;
|
||||
|
||||
import de.cannamanage.domain.enums.EventType;
|
||||
import de.cannamanage.domain.enums.RecurrenceRule;
|
||||
import de.cannamanage.domain.enums.RsvpStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record EventResponse(
|
||||
UUID id,
|
||||
String title,
|
||||
String description,
|
||||
EventType eventType,
|
||||
Instant startAt,
|
||||
Instant endAt,
|
||||
String location,
|
||||
Integer maxAttendees,
|
||||
boolean recurring,
|
||||
RecurrenceRule recurrenceRule,
|
||||
LocalDate recurrenceEndDate,
|
||||
UUID createdBy,
|
||||
Instant createdAt,
|
||||
Map<RsvpStatus, Long> attendeeCounts,
|
||||
RsvpStatus myRsvpStatus
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.cannamanage.api.dto.event;
|
||||
|
||||
import de.cannamanage.domain.enums.RsvpStatus;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record RsvpRequest(
|
||||
@NotNull RsvpStatus status
|
||||
) {}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.event;
|
||||
|
||||
import de.cannamanage.domain.enums.RsvpStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RsvpResponse(
|
||||
UUID memberId,
|
||||
String memberName,
|
||||
RsvpStatus status,
|
||||
Instant respondedAt
|
||||
) {}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.cannamanage.api.dto.event;
|
||||
|
||||
import de.cannamanage.domain.enums.EventType;
|
||||
import de.cannamanage.domain.enums.RecurrenceRule;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record UpdateEventRequest(
|
||||
@NotBlank @Size(max = 200) String title,
|
||||
String description,
|
||||
@NotNull EventType eventType,
|
||||
@NotNull Instant startAt,
|
||||
Instant endAt,
|
||||
@Size(max = 300) String location,
|
||||
Integer maxAttendees,
|
||||
boolean recurring,
|
||||
RecurrenceRule recurrenceRule,
|
||||
LocalDate recurrenceEndDate
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AssignFeeRequest(
|
||||
@NotNull UUID feeScheduleId,
|
||||
@NotNull LocalDate validFrom
|
||||
) {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateFeeScheduleRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotNull FeeInterval interval,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.ExpenseCategory;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record RecordExpenseRequest(
|
||||
@NotNull ExpenseCategory category,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotBlank String description,
|
||||
String reference,
|
||||
@NotNull LocalDate transactionDate
|
||||
) {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.PaymentMethod;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecordPaymentRequest(
|
||||
@NotNull UUID memberId,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotNull PaymentMethod paymentMethod,
|
||||
@NotNull LocalDate periodFrom,
|
||||
@NotNull LocalDate periodTo,
|
||||
String reference,
|
||||
String notes
|
||||
) {}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
|
||||
public record UpdateFeeScheduleRequest(
|
||||
String name,
|
||||
Integer amountCents,
|
||||
FeeInterval interval,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record VoidPaymentRequest(
|
||||
@NotBlank String reason
|
||||
) {}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package de.cannamanage.api.dto.notification;
|
||||
|
||||
import de.cannamanage.domain.enums.TargetType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Request DTO for composing and sending a notification.
|
||||
*/
|
||||
public record ComposeNotificationRequest(
|
||||
@NotBlank String title,
|
||||
@NotBlank String message,
|
||||
String link,
|
||||
@NotNull TargetType targetType,
|
||||
List<UUID> recipientIds
|
||||
) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package de.cannamanage.api.dto.notification;
|
||||
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* Request DTO for registering a push notification device token.
|
||||
*/
|
||||
public record RegisterDeviceRequest(
|
||||
@NotNull DevicePlatform platform,
|
||||
@NotBlank String token,
|
||||
String deviceName
|
||||
) {}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.cannamanage.api.dto.notification;
|
||||
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Request DTO for updating notification preferences.
|
||||
*/
|
||||
public record UpdatePreferencesRequest(
|
||||
Map<NotificationChannel, Boolean> preferences
|
||||
) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package de.cannamanage.api.dto.report;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request body for the authority export endpoint.
|
||||
* Requires re-authentication (password) and a mandatory reason for the audit trail.
|
||||
*/
|
||||
public record AuthorityExportRequest(
|
||||
@NotNull Integer year,
|
||||
@NotBlank @Size(min = 1, max = 500) String password,
|
||||
@NotBlank @Size(min = 10, max = 500) String reason
|
||||
) {
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -29,6 +30,32 @@ public class JwtService {
|
||||
@Value("${cannamanage.security.jwt.refresh-token-expiry:2592000}")
|
||||
private long refreshTokenExpiry; // seconds (30 days)
|
||||
|
||||
/**
|
||||
* Sentinel value used in the application.properties default. If the runtime JWT secret
|
||||
* matches this string (or is missing/too short) the application must fail to start —
|
||||
* we never want a deployment to silently fall back to a publicly-known dev secret.
|
||||
*/
|
||||
static final String UNCONFIGURED_SECRET_MARKER = "CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP";
|
||||
|
||||
/**
|
||||
* Validate JWT secret on startup — fail fast if the deployment is missing a proper secret.
|
||||
* Runs after Spring property binding (@Value) so we see the effective value.
|
||||
*/
|
||||
@PostConstruct
|
||||
void validateSecret() {
|
||||
if (secretKey == null
|
||||
|| secretKey.isBlank()
|
||||
|| secretKey.length() < 32
|
||||
|| UNCONFIGURED_SECRET_MARKER.equals(secretKey)) {
|
||||
throw new IllegalStateException(
|
||||
"FATAL: JWT secret is not configured or uses the default dev placeholder. "
|
||||
+ "Set the CANNAMANAGE_SECURITY_JWT_SECRET environment variable "
|
||||
+ "(or cannamanage.security.jwt.secret property) to a base64-encoded "
|
||||
+ "256-bit (or larger) random key."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for ADMIN/MEMBER roles (no permissions claim needed).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Simple in-memory brute-force protection for the login endpoint.
|
||||
*
|
||||
* <p>Tracks attempts per source IP and rejects further attempts once the
|
||||
* configured threshold ({@link #MAX_ATTEMPTS_PER_WINDOW}) is exceeded within
|
||||
* the current 60-second window. Counters are reset every minute by
|
||||
* {@link #resetCounters()}.
|
||||
*
|
||||
* <p>This deliberately stays in-memory rather than introducing Resilience4j /
|
||||
* Bucket4j for a single endpoint. For multi-instance deployments behind a
|
||||
* load balancer this should be revisited.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
/** Maximum failed/total login attempts allowed per IP per window. */
|
||||
public static final int MAX_ATTEMPTS_PER_WINDOW = 5;
|
||||
|
||||
private final ConcurrentHashMap<String, AtomicInteger> attemptsByIp = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Records an attempt and returns {@code true} if the request is allowed
|
||||
* (still within the per-window quota), {@code false} if it must be
|
||||
* rejected with HTTP 429.
|
||||
*/
|
||||
public boolean tryAcquire(String ipAddress) {
|
||||
if (ipAddress == null || ipAddress.isBlank()) {
|
||||
ipAddress = "unknown";
|
||||
}
|
||||
AtomicInteger counter = attemptsByIp.computeIfAbsent(ipAddress, k -> new AtomicInteger(0));
|
||||
int current = counter.incrementAndGet();
|
||||
if (current > MAX_ATTEMPTS_PER_WINDOW) {
|
||||
log.warn("Login rate limit exceeded for IP {} ({} attempts in current window)", ipAddress, current);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all counters every 60 seconds. Fixed-rate scheduler keeps the
|
||||
* implementation predictable and free of timestamp bookkeeping.
|
||||
*/
|
||||
@Scheduled(fixedRate = 60_000L)
|
||||
public void resetCounters() {
|
||||
if (!attemptsByIp.isEmpty()) {
|
||||
log.debug("Resetting login rate-limit counters for {} IPs", attemptsByIp.size());
|
||||
attemptsByIp.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,14 @@ public class SecurityConfig {
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
private final PortalUserDetailsService portalUserDetailsService;
|
||||
|
||||
/**
|
||||
* Comma-separated allowed CORS origins. Defaults to local dev origins; production
|
||||
* deployments override via the {@code CORS_ORIGINS} environment variable
|
||||
* (e.g. {@code https://cannamanage.plate-software.de}).
|
||||
*/
|
||||
@Value("${cannamanage.cors.allowed-origins:http://localhost:3000,http://frontend:3000}")
|
||||
private String allowedOrigins;
|
||||
|
||||
/**
|
||||
* API security — stateless JWT authentication.
|
||||
* URL-level role checks provide first layer; @PreAuthorize provides fine-grained.
|
||||
@@ -47,6 +56,10 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/webhooks/**").permitAll()
|
||||
@@ -58,6 +71,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
|
||||
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
|
||||
// Documents endpoint — explicit listing for defense-in-depth so it can
|
||||
// never accidentally end up in a permitAll() rule above. Per-document
|
||||
// tenant ownership is additionally enforced in DocumentController.
|
||||
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
@@ -78,6 +95,10 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(1))
|
||||
.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp.policyDirectives(
|
||||
"default-src 'self'; frame-ancestors 'none'"))
|
||||
.frameOptions(frame -> frame.deny()))
|
||||
.userDetailsService(portalUserDetailsService)
|
||||
.formLogin(form -> form
|
||||
.loginProcessingUrl("/portal/login")
|
||||
@@ -128,10 +149,11 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of(
|
||||
"http://localhost:3000",
|
||||
"http://frontend:3000"
|
||||
));
|
||||
List<String> origins = Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
config.setAllowedOrigins(origins);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -54,4 +54,56 @@ public class StaffPermissionChecker {
|
||||
.map(staff -> staff.hasPermission(required))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative permission check — throws AccessDeniedException if permission is missing.
|
||||
* Used by controllers that need to guard specific endpoints programmatically.
|
||||
*/
|
||||
public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) {
|
||||
if (principal == null) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
|
||||
}
|
||||
// Convert UserDetails to Authentication-like check
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
boolean isAdmin = principal.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_ADMIN"));
|
||||
if (isAdmin) return;
|
||||
|
||||
boolean isStaff = principal.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.anyMatch(a -> a.equals("ROLE_STAFF"));
|
||||
if (!isStaff) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions");
|
||||
}
|
||||
|
||||
boolean hasPermission = staffAccountRepository.findByUserId(userId)
|
||||
.filter(StaffAccount::isActive)
|
||||
.map(staff -> staff.hasPermission(required))
|
||||
.orElse(false);
|
||||
if (!hasPermission) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the user ID from the authenticated principal.
|
||||
*/
|
||||
public UUID getUserId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return UUID.fromString(principal.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the club ID (tenant) for the authenticated user.
|
||||
*/
|
||||
public UUID getClubId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tenant ID for the authenticated user (alias for getClubId).
|
||||
*/
|
||||
public UUID getTenantId(org.springframework.security.core.userdetails.UserDetails principal) {
|
||||
return de.cannamanage.domain.entity.TenantContext.getCurrentTenant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
|
||||
|
||||
# Enable Flyway for container startup (fresh DB)
|
||||
spring.flyway.enabled=true
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# JWT secret from environment
|
||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET}
|
||||
@@ -17,6 +17,24 @@ management.endpoint.health.show-details=never
|
||||
# drag /actuator/health to DOWN (503), which would mark the container unhealthy.
|
||||
management.health.mail.enabled=false
|
||||
|
||||
# Disable mail in Docker (no SMTP container)
|
||||
spring.mail.host=localhost
|
||||
spring.mail.port=1025
|
||||
# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
|
||||
spring.mail.host=${SMTP_HOST:smtp.ionos.de}
|
||||
spring.mail.port=${SMTP_PORT:587}
|
||||
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||
spring.mail.password=${IONOS_SMTP_PASSWORD:}
|
||||
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true}
|
||||
spring.mail.properties.mail.smtp.starttls.required=true
|
||||
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||
cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50}
|
||||
|
||||
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
|
||||
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||
push.vapid.subject=mailto:admin@cannamanage.de
|
||||
|
||||
# Firebase Cloud Messaging
|
||||
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||
|
||||
@@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false
|
||||
|
||||
# App base URL
|
||||
app.base-url=https://cannamanage.plate-software.de
|
||||
|
||||
# IONOS SMTP relay (plate-software.de)
|
||||
spring.mail.host=smtp.ionos.de
|
||||
spring.mail.port=587
|
||||
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
|
||||
spring.mail.password=${IONOS_SMTP_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||
spring.mail.properties.mail.smtp.starttls.required=true
|
||||
spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
|
||||
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
|
||||
cannamanage.mail.rate-limit=50
|
||||
|
||||
# Web Push VAPID keys
|
||||
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
|
||||
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
|
||||
push.vapid.subject=mailto:admin@cannamanage.plate-software.de
|
||||
|
||||
# Firebase Cloud Messaging
|
||||
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
|
||||
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
|
||||
|
||||
@@ -5,7 +5,12 @@ spring.jpa.properties.hibernate.packagesToScan=de.cannamanage.domain.entity
|
||||
spring.flyway.enabled=false
|
||||
|
||||
# JWT Security
|
||||
cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI=
|
||||
# DO NOT ship a usable default secret. JwtService.validateSecret() detects the marker below
|
||||
# and refuses to start, forcing every deployment to provide a real base64-encoded 256-bit key
|
||||
# via the CANNAMANAGE_SECURITY_JWT_SECRET environment variable (or override property).
|
||||
# Test/integration profiles pin their own valid dev secret in application-test.properties /
|
||||
# application-integration.properties.
|
||||
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
|
||||
cannamanage.security.jwt.access-token-expiry=3600
|
||||
cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||
|
||||
@@ -38,5 +43,19 @@ management.endpoint.health.show-details=never
|
||||
# Session configuration (member portal)
|
||||
server.servlet.session.timeout=30m
|
||||
server.servlet.session.cookie.same-site=strict
|
||||
|
||||
# Schedulers
|
||||
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
|
||||
server.servlet.session.cookie.http-only=true
|
||||
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||
|
||||
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
|
||||
spring.servlet.multipart.enabled=true
|
||||
spring.servlet.multipart.max-file-size=5MB
|
||||
spring.servlet.multipart.max-request-size=6MB
|
||||
|
||||
# Security hardening — limit non-multipart request body sizes to prevent DoS via oversized payloads
|
||||
server.tomcat.max-http-form-post-size=2MB
|
||||
|
||||
# CORS allowed origins (comma-separated). Override via CORS_ORIGINS env var in production.
|
||||
cannamanage.cors.allowed-origins=${CORS_ORIGINS:http://localhost:3000,http://frontend:3000}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Sprint 7 Phase 1: Notification sends (admin compose + broadcast tracking)
|
||||
-- Tracks each "send" operation (one admin → many members)
|
||||
|
||||
CREATE TABLE notification_sends (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
link VARCHAR(500),
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
target_type VARCHAR(20) NOT NULL, -- ALL or SELECTED
|
||||
target_count INTEGER NOT NULL,
|
||||
read_count INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notification_send_recipients (
|
||||
send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
notification_id UUID REFERENCES notifications(id),
|
||||
PRIMARY KEY (send_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Sprint 7 Phase 1B: Push notification infrastructure
|
||||
-- Device token registry (Web Push subscriptions + mobile push tokens)
|
||||
|
||||
CREATE TABLE device_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(20) NOT NULL, -- WEB, IOS, ANDROID
|
||||
token TEXT NOT NULL, -- Push subscription JSON (Web) or FCM token (mobile)
|
||||
device_name VARCHAR(100), -- e.g. "Chrome on MacBook", "iPhone 15"
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(user_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
|
||||
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);
|
||||
|
||||
-- Per-user notification channel preferences
|
||||
CREATE TABLE notification_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
channel VARCHAR(20) NOT NULL, -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(user_id, channel)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- V13: Info Board (Schwarzes Brett) tables
|
||||
|
||||
CREATE TABLE info_board_posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
is_archived BOOLEAN DEFAULT FALSE,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100),
|
||||
file_size BIGINT,
|
||||
storage_path VARCHAR(500) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_read_status (
|
||||
post_id UUID NOT NULL REFERENCES info_board_posts(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMP DEFAULT NOW(),
|
||||
PRIMARY KEY (post_id, member_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_info_board_posts_club_id ON info_board_posts(club_id);
|
||||
CREATE INDEX idx_info_board_posts_category ON info_board_posts(category);
|
||||
CREATE INDEX idx_info_board_posts_pinned ON info_board_posts(is_pinned) WHERE is_pinned = TRUE;
|
||||
CREATE INDEX idx_info_board_posts_tenant ON info_board_posts(tenant_id);
|
||||
CREATE INDEX idx_post_attachments_post_id ON post_attachments(post_id);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Sprint 7 Phase 2.5: Club Event Calendar
|
||||
-- Club events with RSVP support, recurring events, and iCal export
|
||||
|
||||
CREATE TABLE club_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_at TIMESTAMP WITH TIME ZONE,
|
||||
location VARCHAR(300),
|
||||
max_attendees INTEGER,
|
||||
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recurrence_rule VARCHAR(100),
|
||||
recurrence_end_date DATE,
|
||||
reminder_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
|
||||
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);
|
||||
CREATE INDEX idx_club_events_club_id ON club_events(club_id);
|
||||
|
||||
-- Event RSVPs
|
||||
CREATE TABLE event_rsvps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
status VARCHAR(20) NOT NULL,
|
||||
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
UNIQUE(event_id, member_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
|
||||
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);
|
||||
@@ -0,0 +1,61 @@
|
||||
-- V15: Forum MVP — topics, replies, reactions, reports
|
||||
-- Phase 3 of Sprint 7
|
||||
|
||||
CREATE TABLE forum_topics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
is_locked BOOLEAN DEFAULT FALSE,
|
||||
is_pinned BOOLEAN DEFAULT FALSE,
|
||||
reply_count INTEGER DEFAULT 0,
|
||||
last_reply_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE forum_replies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
topic_id UUID NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id UUID NOT NULL REFERENCES users(id),
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
edited_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE forum_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
target_type VARCHAR(10) NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
reaction_type VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(target_type, target_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE forum_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
target_type VARCHAR(10) NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
reporter_id UUID NOT NULL REFERENCES users(id),
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'OPEN',
|
||||
reviewed_by UUID REFERENCES users(id),
|
||||
reviewed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_forum_topics_club_id ON forum_topics(club_id);
|
||||
CREATE INDEX idx_forum_topics_tenant_id ON forum_topics(tenant_id);
|
||||
CREATE INDEX idx_forum_replies_topic_id ON forum_replies(topic_id);
|
||||
CREATE INDEX idx_forum_replies_tenant_id ON forum_replies(tenant_id);
|
||||
CREATE INDEX idx_forum_reactions_target ON forum_reactions(target_type, target_id);
|
||||
CREATE INDEX idx_forum_reports_club_status ON forum_reports(club_id, status);
|
||||
CREATE INDEX idx_forum_reports_tenant_id ON forum_reports(tenant_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- V16: Index for faster email dispatch queries on notification_preferences
|
||||
-- Used by NotificationDispatchService to find users with EMAIL channel enabled per tenant
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_preferences_email_enabled
|
||||
ON notification_preferences(tenant_id, channel, enabled)
|
||||
WHERE channel = 'EMAIL' AND enabled = true;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- V17: Custom mail domains for Enterprise tier clubs
|
||||
-- Allows Enterprise clubs to use a verified custom FROM address for outbound emails
|
||||
|
||||
CREATE TABLE custom_mail_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL UNIQUE,
|
||||
from_address VARCHAR(255) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
verification_token VARCHAR(64) NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT false,
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_custom_mail_domains_tenant ON custom_mail_domains(tenant_id);
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Sprint 8: Treasury / Finance tables
|
||||
-- Fee schedules (Beitragsordnung)
|
||||
CREATE TABLE fee_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
interval VARCHAR(20) NOT NULL,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Member fee assignment
|
||||
CREATE TABLE member_fee_assignments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(member_id, valid_from)
|
||||
);
|
||||
|
||||
-- Payments (Zahlungen)
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
amount_cents INTEGER NOT NULL,
|
||||
payment_method VARCHAR(30) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PAID',
|
||||
period_from DATE NOT NULL,
|
||||
period_to DATE NOT NULL,
|
||||
reference VARCHAR(200),
|
||||
notes TEXT,
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
paid_at TIMESTAMP NOT NULL,
|
||||
voided_at TIMESTAMP,
|
||||
voided_by UUID REFERENCES users(id),
|
||||
void_reason TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Kassenbuch (cash book / ledger entries) — append-only per §147 AO
|
||||
CREATE TABLE ledger_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
tenant_id UUID NOT NULL,
|
||||
transaction_type VARCHAR(10) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
description VARCHAR(500) NOT NULL,
|
||||
reference VARCHAR(200),
|
||||
payment_id UUID REFERENCES payments(id),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
transaction_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_fee_schedules_club ON fee_schedules(club_id);
|
||||
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id);
|
||||
CREATE INDEX idx_member_fee_assignments_member ON member_fee_assignments(member_id);
|
||||
CREATE INDEX idx_member_fee_assignments_tenant ON member_fee_assignments(tenant_id);
|
||||
CREATE INDEX idx_payments_club_member ON payments(club_id, member_id);
|
||||
CREATE INDEX idx_payments_status ON payments(club_id, status);
|
||||
CREATE INDEX idx_payments_period ON payments(club_id, period_from, period_to);
|
||||
CREATE INDEX idx_payments_tenant ON payments(tenant_id);
|
||||
CREATE INDEX idx_ledger_entries_club_date ON ledger_entries(club_id, transaction_date);
|
||||
CREATE INDEX idx_ledger_entries_category ON ledger_entries(club_id, category);
|
||||
CREATE INDEX idx_ledger_entries_tenant ON ledger_entries(tenant_id);
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Sprint 8 Phase 3: Mitgliederversammlung (General Assembly)
|
||||
-- Legal basis: §32 BGB (Mitgliederversammlung), §33 BGB (Satzungsänderung),
|
||||
-- §67 BGB (Vereinsregister), §147 AO (Aufbewahrungspflicht)
|
||||
|
||||
-- General assemblies (Mitgliederversammlungen)
|
||||
CREATE TABLE assemblies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
assembly_type VARCHAR(30) NOT NULL,
|
||||
scheduled_at TIMESTAMP NOT NULL,
|
||||
location VARCHAR(300),
|
||||
invitation_sent_at TIMESTAMP,
|
||||
invitation_deadline DATE,
|
||||
quorum_required INTEGER,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'PLANNED',
|
||||
opened_at TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Agenda items (Tagesordnungspunkte / TOP)
|
||||
CREATE TABLE assembly_agenda_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
item_type VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Attendance
|
||||
CREATE TABLE assembly_attendees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
checked_in_at TIMESTAMP DEFAULT NOW(),
|
||||
proxy_for_member_id UUID REFERENCES members(id),
|
||||
UNIQUE(assembly_id, member_id)
|
||||
);
|
||||
|
||||
-- Votes (Abstimmungen)
|
||||
CREATE TABLE assembly_votes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assembly_id UUID NOT NULL REFERENCES assemblies(id) ON DELETE CASCADE,
|
||||
agenda_item_id UUID NOT NULL REFERENCES assembly_agenda_items(id),
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
vote_type VARCHAR(30) NOT NULL,
|
||||
yes_count INTEGER DEFAULT 0,
|
||||
no_count INTEGER DEFAULT 0,
|
||||
abstain_count INTEGER DEFAULT 0,
|
||||
result VARCHAR(20),
|
||||
voted_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Individual vote records (for transparency, not secret ballot)
|
||||
CREATE TABLE assembly_vote_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vote_id UUID NOT NULL REFERENCES assembly_votes(id) ON DELETE CASCADE,
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
decision VARCHAR(10) NOT NULL,
|
||||
voted_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(vote_id, member_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_assemblies_club ON assemblies(club_id);
|
||||
CREATE INDEX idx_assemblies_tenant ON assemblies(tenant_id);
|
||||
CREATE INDEX idx_assemblies_status ON assemblies(club_id, status);
|
||||
CREATE INDEX idx_agenda_items_assembly ON assembly_agenda_items(assembly_id);
|
||||
CREATE INDEX idx_attendees_assembly ON assembly_attendees(assembly_id);
|
||||
CREATE INDEX idx_votes_assembly ON assembly_votes(assembly_id);
|
||||
CREATE INDEX idx_vote_records_vote ON assembly_vote_records(vote_id);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- V20: Document archive for club documents (Satzung, Protokolle, Verträge, etc.)
|
||||
-- Legal basis: §22 KCanG (Dokumentationspflichten), §147 AO (Aufbewahrungspflichten)
|
||||
|
||||
CREATE TABLE documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(300) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(100) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
storage_path VARCHAR(500) NOT NULL,
|
||||
access_level VARCHAR(20) NOT NULL DEFAULT 'ALL_MEMBERS',
|
||||
description TEXT,
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_club ON documents(club_id);
|
||||
CREATE INDEX idx_documents_category ON documents(club_id, category);
|
||||
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- V21: Board management (Vorstandsverwaltung)
|
||||
-- Legal basis: §26 BGB (Vorstand), §27 BGB (Bestellung/Abberufung), §23 KCanG (Präventionsbeauftragter)
|
||||
|
||||
CREATE TABLE board_positions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
title VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE board_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
position_id UUID NOT NULL REFERENCES board_positions(id),
|
||||
member_id UUID NOT NULL REFERENCES members(id),
|
||||
elected_at DATE NOT NULL,
|
||||
term_start DATE NOT NULL,
|
||||
term_end DATE,
|
||||
is_current BOOLEAN DEFAULT TRUE,
|
||||
elected_in_assembly_id UUID REFERENCES assemblies(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_board_positions_club ON board_positions(club_id);
|
||||
CREATE INDEX idx_board_positions_tenant ON board_positions(tenant_id);
|
||||
CREATE INDEX idx_board_members_club ON board_members(club_id);
|
||||
CREATE INDEX idx_board_members_tenant ON board_members(tenant_id);
|
||||
CREATE INDEX idx_board_members_current ON board_members(club_id, is_current) WHERE is_current = TRUE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- V22: Add protocol_document_id to assemblies for auto-archive feature
|
||||
ALTER TABLE assemblies ADD COLUMN IF NOT EXISTS protocol_document_id UUID;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Destruction records per KCanG §22
|
||||
CREATE TABLE destruction_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
batch_id UUID REFERENCES batches(id),
|
||||
amount_grams NUMERIC(8,2) NOT NULL,
|
||||
destruction_method VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
destroyed_at TIMESTAMP NOT NULL,
|
||||
witnessed_by UUID REFERENCES users(id),
|
||||
witness_name VARCHAR(200),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_destruction_records_tenant ON destruction_records(tenant_id);
|
||||
CREATE INDEX idx_destruction_records_club ON destruction_records(club_id);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Sprint 9: Transport records per KCanG §22 transport documentation
|
||||
CREATE TABLE transport_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
description TEXT NOT NULL,
|
||||
transport_date DATE NOT NULL,
|
||||
from_location VARCHAR(300) NOT NULL,
|
||||
to_location VARCHAR(300) NOT NULL,
|
||||
carrier_name VARCHAR(200) NOT NULL,
|
||||
amount_grams NUMERIC(8,2) NOT NULL,
|
||||
batch_id UUID REFERENCES batches(id),
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PLANNED',
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transport_records_tenant ON transport_records(tenant_id);
|
||||
CREATE INDEX idx_transport_records_club ON transport_records(club_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Sprint 9: Propagation sources (seed/cutting tracking per KCanG §16)
|
||||
CREATE TABLE propagation_sources (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
source_type VARCHAR(50) NOT NULL, -- SEED, CUTTING
|
||||
supplier VARCHAR(300),
|
||||
quantity INTEGER NOT NULL,
|
||||
strain_id UUID REFERENCES strains(id),
|
||||
received_at DATE NOT NULL,
|
||||
documentation_reference VARCHAR(200),
|
||||
recorded_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_propagation_sources_tenant ON propagation_sources(tenant_id);
|
||||
CREATE INDEX idx_propagation_sources_club ON propagation_sources(club_id);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Sprint 9: Prevention activities per KCanG §23 Suchtprävention
|
||||
CREATE TABLE prevention_activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
activity_date DATE NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
participants_count INTEGER,
|
||||
officer_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_prevention_activities_tenant ON prevention_activities(tenant_id);
|
||||
CREATE INDEX idx_prevention_activities_club ON prevention_activities(club_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Generated reports metadata
|
||||
CREATE TABLE generated_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
report_type VARCHAR(50) NOT NULL,
|
||||
report_format VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
file_size BIGINT,
|
||||
storage_path VARCHAR(500),
|
||||
parameters JSONB,
|
||||
generated_by UUID NOT NULL REFERENCES users(id),
|
||||
generated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
|
||||
CREATE INDEX idx_generated_reports_club ON generated_reports(club_id);
|
||||
CREATE INDEX idx_generated_reports_type ON generated_reports(club_id, report_type);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Sprint 9: Compliance deadlines tracking
|
||||
CREATE TABLE compliance_deadlines (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||
area VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(300) NOT NULL,
|
||||
description TEXT,
|
||||
due_date DATE NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT FALSE,
|
||||
recurrence_rule VARCHAR(50),
|
||||
completed_at TIMESTAMP,
|
||||
completed_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_compliance_deadlines_tenant ON compliance_deadlines(tenant_id);
|
||||
CREATE INDEX idx_compliance_deadlines_club_due ON compliance_deadlines(club_id, due_date);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Sprint 9: Add THC/CBD percentage + strain name to distributions (KCanG §19(4))
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS thc_percentage NUMERIC(4,2);
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS cbd_percentage NUMERIC(4,2);
|
||||
ALTER TABLE distributions ADD COLUMN IF NOT EXISTS strain_name VARCHAR(200);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Sprint 10: Bank statement import sessions
|
||||
-- Each upload of a bank statement creates one session, which is then matched + reviewed by an admin.
|
||||
-- Status flow: PENDING → IN_REVIEW → COMPLETED (or FAILED at any point).
|
||||
-- Once COMPLETED, the session is immutable per GoBD requirements (§147 AO).
|
||||
CREATE TABLE bank_import_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
format VARCHAR(20) NOT NULL, -- MT940, CAMT053, CSV
|
||||
total_transactions INTEGER NOT NULL DEFAULT 0,
|
||||
matched_count INTEGER NOT NULL DEFAULT 0,
|
||||
confirmed_count INTEGER NOT NULL DEFAULT 0,
|
||||
skipped_count INTEGER NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, IN_REVIEW, COMPLETED, FAILED
|
||||
uploaded_by UUID NOT NULL REFERENCES users(id),
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bank_import_sessions_tenant ON bank_import_sessions(tenant_id);
|
||||
CREATE INDEX idx_bank_import_sessions_club ON bank_import_sessions(club_id);
|
||||
CREATE INDEX idx_bank_import_sessions_status ON bank_import_sessions(club_id, status);
|
||||
CREATE INDEX idx_bank_import_sessions_created ON bank_import_sessions(club_id, created_at DESC);
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Sprint 10: Parsed bank transactions
|
||||
-- One row per transaction in an uploaded bank statement.
|
||||
-- amount_cents: positive = incoming (potential member payment), negative = outgoing (expense).
|
||||
-- match_status drives the review UI: UNMATCHED/SUGGESTED/MATCHED/CONFIRMED/SKIPPED.
|
||||
-- CASCADE on session delete: discarding a draft session also deletes its parsed rows.
|
||||
-- SET NULL on member/payment delete: history is preserved even if the matched entity is removed.
|
||||
CREATE TABLE bank_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
session_id UUID NOT NULL REFERENCES bank_import_sessions(id) ON DELETE CASCADE,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
booking_date DATE NOT NULL,
|
||||
value_date DATE,
|
||||
amount_cents INTEGER NOT NULL, -- positive = incoming, negative = outgoing
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
reference_text TEXT, -- Verwendungszweck
|
||||
counterparty_name VARCHAR(300),
|
||||
counterparty_iban VARCHAR(34),
|
||||
bank_reference VARCHAR(100),
|
||||
match_status VARCHAR(20) NOT NULL DEFAULT 'UNMATCHED',-- UNMATCHED, SUGGESTED, MATCHED, CONFIRMED, SKIPPED
|
||||
match_confidence INTEGER, -- 0-100, only populated when match_status != UNMATCHED
|
||||
matched_member_id UUID REFERENCES members(id) ON DELETE SET NULL,
|
||||
matched_payment_id UUID REFERENCES payments(id) ON DELETE SET NULL,
|
||||
skip_reason VARCHAR(100),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bank_transactions_tenant ON bank_transactions(tenant_id);
|
||||
CREATE INDEX idx_bank_transactions_session ON bank_transactions(session_id);
|
||||
CREATE INDEX idx_bank_transactions_club_status ON bank_transactions(club_id, match_status);
|
||||
CREATE INDEX idx_bank_transactions_member ON bank_transactions(matched_member_id);
|
||||
CREATE INDEX idx_bank_transactions_payment ON bank_transactions(matched_payment_id);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Sprint 10: CSV column mapping templates + member IBAN fields
|
||||
-- CSV files have no standard layout — each bank uses different columns/encodings.
|
||||
-- Admins create a named mapping per bank (e.g. "Sparkasse Export") that the parser reuses.
|
||||
CREATE TABLE csv_column_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
club_id UUID NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL, -- e.g. "Sparkasse Export"
|
||||
date_column INTEGER NOT NULL,
|
||||
amount_column INTEGER NOT NULL,
|
||||
reference_column INTEGER,
|
||||
counterparty_column INTEGER,
|
||||
iban_column INTEGER,
|
||||
delimiter VARCHAR(5) NOT NULL DEFAULT ';',
|
||||
date_format VARCHAR(20) NOT NULL DEFAULT 'dd.MM.yyyy',
|
||||
decimal_separator VARCHAR(1) NOT NULL DEFAULT ',',
|
||||
skip_header_rows INTEGER NOT NULL DEFAULT 1,
|
||||
encoding VARCHAR(20) NOT NULL DEFAULT 'ISO-8859-1',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_csv_column_mappings_tenant ON csv_column_mappings(tenant_id);
|
||||
CREATE INDEX idx_csv_column_mappings_club ON csv_column_mappings(club_id);
|
||||
|
||||
-- Add optional IBAN fields to members.
|
||||
-- Both columns are intentionally NULLABLE — IBAN is only populated after explicit
|
||||
-- BANK_DATA consent (DSGVO Art. 6(1)(a)). ibanConsentDate records when consent was given.
|
||||
-- PostgreSQL adds nullable columns instantly (no table rewrite), safe for production.
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMP;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection.
|
||||
-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup
|
||||
-- (a renamed copy of the same file is still detected).
|
||||
ALTER TABLE bank_import_sessions
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
|
||||
-- Unique per club to allow the same file to be imported by different tenants in DEV/QA.
|
||||
-- NULL values are allowed for legacy rows created before V33.
|
||||
CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash
|
||||
ON bank_import_sessions(club_id, file_hash)
|
||||
WHERE file_hash IS NOT NULL;
|
||||
@@ -18,3 +18,6 @@ cannamanage.security.jwt.refresh-token-expiry=2592000
|
||||
# AOP
|
||||
spring.aop.auto=true
|
||||
spring.aop.proxy-target-class=true
|
||||
|
||||
# Disable schedulers in tests
|
||||
cannamanage.schedulers.enabled=false
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.AssemblyStatus;
|
||||
import de.cannamanage.domain.enums.AssemblyType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* General assembly (Mitgliederversammlung) entity.
|
||||
* Legal basis: §32 BGB (decision-making organ), §36 BGB (notice period).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "assemblies", indexes = {
|
||||
@Index(name = "idx_assemblies_club", columnList = "club_id"),
|
||||
@Index(name = "idx_assemblies_tenant", columnList = "tenant_id"),
|
||||
@Index(name = "idx_assemblies_status", columnList = "club_id, status")
|
||||
})
|
||||
public class Assembly extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "assembly_type", nullable = false, length = 30)
|
||||
private AssemblyType assemblyType;
|
||||
|
||||
@Column(name = "scheduled_at", nullable = false)
|
||||
private Instant scheduledAt;
|
||||
|
||||
@Column(name = "location", length = 300)
|
||||
private String location;
|
||||
|
||||
@Column(name = "invitation_sent_at")
|
||||
private Instant invitationSentAt;
|
||||
|
||||
@Column(name = "invitation_deadline")
|
||||
private LocalDate invitationDeadline;
|
||||
|
||||
@Column(name = "quorum_required")
|
||||
private Integer quorumRequired;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
private AssemblyStatus status = AssemblyStatus.PLANNED;
|
||||
|
||||
@Column(name = "opened_at")
|
||||
private Instant openedAt;
|
||||
|
||||
@Column(name = "closed_at")
|
||||
private Instant closedAt;
|
||||
|
||||
@Column(name = "protocol_document_id")
|
||||
private UUID protocolDocumentId;
|
||||
|
||||
@Column(name = "created_by", nullable = false)
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public AssemblyType getAssemblyType() { return assemblyType; }
|
||||
public void setAssemblyType(AssemblyType assemblyType) { this.assemblyType = assemblyType; }
|
||||
|
||||
public Instant getScheduledAt() { return scheduledAt; }
|
||||
public void setScheduledAt(Instant scheduledAt) { this.scheduledAt = scheduledAt; }
|
||||
|
||||
public String getLocation() { return location; }
|
||||
public void setLocation(String location) { this.location = location; }
|
||||
|
||||
public Instant getInvitationSentAt() { return invitationSentAt; }
|
||||
public void setInvitationSentAt(Instant invitationSentAt) { this.invitationSentAt = invitationSentAt; }
|
||||
|
||||
public LocalDate getInvitationDeadline() { return invitationDeadline; }
|
||||
public void setInvitationDeadline(LocalDate invitationDeadline) { this.invitationDeadline = invitationDeadline; }
|
||||
|
||||
public Integer getQuorumRequired() { return quorumRequired; }
|
||||
public void setQuorumRequired(Integer quorumRequired) { this.quorumRequired = quorumRequired; }
|
||||
|
||||
public AssemblyStatus getStatus() { return status; }
|
||||
public void setStatus(AssemblyStatus status) { this.status = status; }
|
||||
|
||||
public Instant getOpenedAt() { return openedAt; }
|
||||
public void setOpenedAt(Instant openedAt) { this.openedAt = openedAt; }
|
||||
|
||||
public Instant getClosedAt() { return closedAt; }
|
||||
public void setClosedAt(Instant closedAt) { this.closedAt = closedAt; }
|
||||
|
||||
public UUID getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||
|
||||
public UUID getProtocolDocumentId() { return protocolDocumentId; }
|
||||
public void setProtocolDocumentId(UUID protocolDocumentId) { this.protocolDocumentId = protocolDocumentId; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.AgendaItemType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Agenda item (Tagesordnungspunkt / TOP) for a general assembly.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "assembly_agenda_items", indexes = {
|
||||
@Index(name = "idx_agenda_items_assembly", columnList = "assembly_id")
|
||||
})
|
||||
public class AssemblyAgendaItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "assembly_id", nullable = false)
|
||||
private UUID assemblyId;
|
||||
|
||||
@Column(name = "position", nullable = false)
|
||||
private Integer position;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "item_type", nullable = false, length = 30)
|
||||
private AgendaItemType itemType;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public UUID getAssemblyId() { return assemblyId; }
|
||||
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||
|
||||
public Integer getPosition() { return position; }
|
||||
public void setPosition(Integer position) { this.position = position; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public AgendaItemType getItemType() { return itemType; }
|
||||
public void setItemType(AgendaItemType itemType) { this.itemType = itemType; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Attendance record for a general assembly.
|
||||
* Supports proxy voting (Vollmacht) via proxyForMemberId.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "assembly_attendees", indexes = {
|
||||
@Index(name = "idx_attendees_assembly", columnList = "assembly_id")
|
||||
}, uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_attendee_assembly_member", columnNames = {"assembly_id", "member_id"})
|
||||
})
|
||||
public class AssemblyAttendee {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "assembly_id", nullable = false)
|
||||
private UUID assemblyId;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private UUID memberId;
|
||||
|
||||
@Column(name = "checked_in_at")
|
||||
private Instant checkedInAt;
|
||||
|
||||
@Column(name = "proxy_for_member_id")
|
||||
private UUID proxyForMemberId;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.checkedInAt == null) {
|
||||
this.checkedInAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public UUID getAssemblyId() { return assemblyId; }
|
||||
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||
|
||||
public UUID getMemberId() { return memberId; }
|
||||
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||
|
||||
public Instant getCheckedInAt() { return checkedInAt; }
|
||||
public void setCheckedInAt(Instant checkedInAt) { this.checkedInAt = checkedInAt; }
|
||||
|
||||
public UUID getProxyForMemberId() { return proxyForMemberId; }
|
||||
public void setProxyForMemberId(UUID proxyForMemberId) { this.proxyForMemberId = proxyForMemberId; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.VoteResult;
|
||||
import de.cannamanage.domain.enums.VoteType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Vote (Abstimmung) entity for a specific agenda item.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "assembly_votes", indexes = {
|
||||
@Index(name = "idx_votes_assembly", columnList = "assembly_id")
|
||||
})
|
||||
public class AssemblyVote {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "assembly_id", nullable = false)
|
||||
private UUID assemblyId;
|
||||
|
||||
@Column(name = "agenda_item_id", nullable = false)
|
||||
private UUID agendaItemId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "vote_type", nullable = false, length = 30)
|
||||
private VoteType voteType;
|
||||
|
||||
@Column(name = "yes_count", nullable = false)
|
||||
private int yesCount = 0;
|
||||
|
||||
@Column(name = "no_count", nullable = false)
|
||||
private int noCount = 0;
|
||||
|
||||
@Column(name = "abstain_count", nullable = false)
|
||||
private int abstainCount = 0;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "result", length = 20)
|
||||
private VoteResult result;
|
||||
|
||||
@Column(name = "voted_at")
|
||||
private Instant votedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public UUID getAssemblyId() { return assemblyId; }
|
||||
public void setAssemblyId(UUID assemblyId) { this.assemblyId = assemblyId; }
|
||||
|
||||
public UUID getAgendaItemId() { return agendaItemId; }
|
||||
public void setAgendaItemId(UUID agendaItemId) { this.agendaItemId = agendaItemId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public VoteType getVoteType() { return voteType; }
|
||||
public void setVoteType(VoteType voteType) { this.voteType = voteType; }
|
||||
|
||||
public int getYesCount() { return yesCount; }
|
||||
public void setYesCount(int yesCount) { this.yesCount = yesCount; }
|
||||
|
||||
public int getNoCount() { return noCount; }
|
||||
public void setNoCount(int noCount) { this.noCount = noCount; }
|
||||
|
||||
public int getAbstainCount() { return abstainCount; }
|
||||
public void setAbstainCount(int abstainCount) { this.abstainCount = abstainCount; }
|
||||
|
||||
public VoteResult getResult() { return result; }
|
||||
public void setResult(VoteResult result) { this.result = result; }
|
||||
|
||||
public Instant getVotedAt() { return votedAt; }
|
||||
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.VoteDecision;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Individual vote record — records each member's decision on a vote.
|
||||
* NOT secret ballot: each member's vote is recorded (standard for most Vereinsversammlungen).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "assembly_vote_records", indexes = {
|
||||
@Index(name = "idx_vote_records_vote", columnList = "vote_id")
|
||||
}, uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_vote_record_vote_member", columnNames = {"vote_id", "member_id"})
|
||||
})
|
||||
public class AssemblyVoteRecord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "vote_id", nullable = false)
|
||||
private UUID voteId;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private UUID memberId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "decision", nullable = false, length = 10)
|
||||
private VoteDecision decision;
|
||||
|
||||
@Column(name = "voted_at", nullable = false)
|
||||
private Instant votedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (this.votedAt == null) {
|
||||
this.votedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public UUID getVoteId() { return voteId; }
|
||||
public void setVoteId(UUID voteId) { this.voteId = voteId; }
|
||||
|
||||
public UUID getMemberId() { return memberId; }
|
||||
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||
|
||||
public VoteDecision getDecision() { return decision; }
|
||||
public void setDecision(VoteDecision decision) { this.decision = decision; }
|
||||
|
||||
public Instant getVotedAt() { return votedAt; }
|
||||
public void setVotedAt(Instant votedAt) { this.votedAt = votedAt; }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.BankFormat;
|
||||
import de.cannamanage.domain.enums.ImportSessionStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 — One upload of a bank statement file. Owns the parsed {@link BankTransaction}s
|
||||
* and tracks review progress until the admin marks the session COMPLETED.
|
||||
* <p>
|
||||
* Status lifecycle: {@code PENDING} → {@code IN_REVIEW} → {@code COMPLETED}.
|
||||
* After COMPLETED the session is immutable per GoBD (§147 AO).
|
||||
* {@code FAILED} is a terminal state for parse errors or discarded sessions.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "bank_import_sessions")
|
||||
public class BankImportSession extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "filename", nullable = false, length = 255)
|
||||
private String filename;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "format", nullable = false, length = 20)
|
||||
private BankFormat format;
|
||||
|
||||
@Column(name = "total_transactions", nullable = false)
|
||||
private Integer totalTransactions = 0;
|
||||
|
||||
@Column(name = "matched_count", nullable = false)
|
||||
private Integer matchedCount = 0;
|
||||
|
||||
@Column(name = "confirmed_count", nullable = false)
|
||||
private Integer confirmedCount = 0;
|
||||
|
||||
@Column(name = "skipped_count", nullable = false)
|
||||
private Integer skippedCount = 0;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private ImportSessionStatus status = ImportSessionStatus.PENDING;
|
||||
|
||||
@Column(name = "uploaded_by", nullable = false)
|
||||
private UUID uploadedBy;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
private String errorMessage;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private Instant completedAt;
|
||||
|
||||
/**
|
||||
* SHA-256 hex digest of the uploaded file content (Sprint 10 Phase 3).
|
||||
* Used together with {@code clubId} to reject byte-identical re-uploads (HTTP 409),
|
||||
* even when the file has been renamed. Nullable for legacy rows created before V33.
|
||||
*/
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getFilename() { return filename; }
|
||||
public void setFilename(String filename) { this.filename = filename; }
|
||||
|
||||
public BankFormat getFormat() { return format; }
|
||||
public void setFormat(BankFormat format) { this.format = format; }
|
||||
|
||||
public Integer getTotalTransactions() { return totalTransactions; }
|
||||
public void setTotalTransactions(Integer totalTransactions) { this.totalTransactions = totalTransactions; }
|
||||
|
||||
public Integer getMatchedCount() { return matchedCount; }
|
||||
public void setMatchedCount(Integer matchedCount) { this.matchedCount = matchedCount; }
|
||||
|
||||
public Integer getConfirmedCount() { return confirmedCount; }
|
||||
public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; }
|
||||
|
||||
public Integer getSkippedCount() { return skippedCount; }
|
||||
public void setSkippedCount(Integer skippedCount) { this.skippedCount = skippedCount; }
|
||||
|
||||
public ImportSessionStatus getStatus() { return status; }
|
||||
public void setStatus(ImportSessionStatus status) { this.status = status; }
|
||||
|
||||
public UUID getUploadedBy() { return uploadedBy; }
|
||||
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
|
||||
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
|
||||
public Instant getCompletedAt() { return completedAt; }
|
||||
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
|
||||
|
||||
public String getFileHash() { return fileHash; }
|
||||
public void setFileHash(String fileHash) { this.fileHash = fileHash; }
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.MatchStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 — One transaction line parsed from a bank statement file.
|
||||
* <p>
|
||||
* Sign convention for {@code amountCents}: <strong>positive = incoming</strong>
|
||||
* (potential member payment), <strong>negative = outgoing</strong> (expense).
|
||||
* <p>
|
||||
* Match flow: parser writes UNMATCHED → matching engine sets MATCHED/SUGGESTED →
|
||||
* admin sets CONFIRMED or SKIPPED. CONFIRMED links to the created {@link Payment}
|
||||
* via {@code matchedPaymentId}.
|
||||
* <p>
|
||||
* Foreign keys to {@code members} and {@code payments} are not modelled as JPA
|
||||
* relationships to keep the entity flat (UUIDs only) — consistent with the rest
|
||||
* of the schema (see {@link Payment} for the same pattern). Deletes use
|
||||
* {@code SET NULL} so transaction history survives member/payment removal.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "bank_transactions")
|
||||
public class BankTransaction extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "session_id", nullable = false, updatable = false)
|
||||
private UUID sessionId;
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "booking_date", nullable = false)
|
||||
private LocalDate bookingDate;
|
||||
|
||||
@Column(name = "value_date")
|
||||
private LocalDate valueDate;
|
||||
|
||||
/** Positive = incoming, negative = outgoing. Stored in cents to avoid floating-point. */
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Integer amountCents;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 3)
|
||||
private String currency = "EUR";
|
||||
|
||||
/** German "Verwendungszweck" — free-text payment reference. */
|
||||
@Column(name = "reference_text", columnDefinition = "TEXT")
|
||||
private String referenceText;
|
||||
|
||||
@Column(name = "counterparty_name", length = 300)
|
||||
private String counterpartyName;
|
||||
|
||||
@Column(name = "counterparty_iban", length = 34)
|
||||
private String counterpartyIban;
|
||||
|
||||
/** Bank's own internal transaction reference (EREF, KREF, MREF). */
|
||||
@Column(name = "bank_reference", length = 100)
|
||||
private String bankReference;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "match_status", nullable = false, length = 20)
|
||||
private MatchStatus matchStatus = MatchStatus.UNMATCHED;
|
||||
|
||||
/** 0-100; only meaningful when {@link #matchStatus} is not UNMATCHED. */
|
||||
@Column(name = "match_confidence")
|
||||
private Integer matchConfidence;
|
||||
|
||||
@Column(name = "matched_member_id")
|
||||
private UUID matchedMemberId;
|
||||
|
||||
@Column(name = "matched_payment_id")
|
||||
private UUID matchedPaymentId;
|
||||
|
||||
@Column(name = "skip_reason", length = 100)
|
||||
private String skipReason;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getSessionId() { return sessionId; }
|
||||
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public LocalDate getBookingDate() { return bookingDate; }
|
||||
public void setBookingDate(LocalDate bookingDate) { this.bookingDate = bookingDate; }
|
||||
|
||||
public LocalDate getValueDate() { return valueDate; }
|
||||
public void setValueDate(LocalDate valueDate) { this.valueDate = valueDate; }
|
||||
|
||||
public Integer getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public String getReferenceText() { return referenceText; }
|
||||
public void setReferenceText(String referenceText) { this.referenceText = referenceText; }
|
||||
|
||||
public String getCounterpartyName() { return counterpartyName; }
|
||||
public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; }
|
||||
|
||||
public String getCounterpartyIban() { return counterpartyIban; }
|
||||
public void setCounterpartyIban(String counterpartyIban) { this.counterpartyIban = counterpartyIban; }
|
||||
|
||||
public String getBankReference() { return bankReference; }
|
||||
public void setBankReference(String bankReference) { this.bankReference = bankReference; }
|
||||
|
||||
public MatchStatus getMatchStatus() { return matchStatus; }
|
||||
public void setMatchStatus(MatchStatus matchStatus) { this.matchStatus = matchStatus; }
|
||||
|
||||
public Integer getMatchConfidence() { return matchConfidence; }
|
||||
public void setMatchConfidence(Integer matchConfidence) { this.matchConfidence = matchConfidence; }
|
||||
|
||||
public UUID getMatchedMemberId() { return matchedMemberId; }
|
||||
public void setMatchedMemberId(UUID matchedMemberId) { this.matchedMemberId = matchedMemberId; }
|
||||
|
||||
public UUID getMatchedPaymentId() { return matchedPaymentId; }
|
||||
public void setMatchedPaymentId(UUID matchedPaymentId) { this.matchedPaymentId = matchedPaymentId; }
|
||||
|
||||
public String getSkipReason() { return skipReason; }
|
||||
public void setSkipReason(String skipReason) { this.skipReason = skipReason; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Board member assignment — links a member to a board position for a term.
|
||||
* Legal basis: §27 BGB (Bestellung/Abberufung des Vorstands).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "board_members", indexes = {
|
||||
@Index(name = "idx_board_members_club", columnList = "club_id"),
|
||||
@Index(name = "idx_board_members_tenant", columnList = "tenant_id")
|
||||
})
|
||||
public class BoardMember extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "position_id", nullable = false)
|
||||
private UUID positionId;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private UUID memberId;
|
||||
|
||||
@Column(name = "elected_at", nullable = false)
|
||||
private LocalDate electedAt;
|
||||
|
||||
@Column(name = "term_start", nullable = false)
|
||||
private LocalDate termStart;
|
||||
|
||||
@Column(name = "term_end")
|
||||
private LocalDate termEnd;
|
||||
|
||||
@Column(name = "is_current", nullable = false)
|
||||
private Boolean isCurrent = true;
|
||||
|
||||
@Column(name = "elected_in_assembly_id")
|
||||
private UUID electedInAssemblyId;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public UUID getPositionId() { return positionId; }
|
||||
public void setPositionId(UUID positionId) { this.positionId = positionId; }
|
||||
|
||||
public UUID getMemberId() { return memberId; }
|
||||
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||
|
||||
public LocalDate getElectedAt() { return electedAt; }
|
||||
public void setElectedAt(LocalDate electedAt) { this.electedAt = electedAt; }
|
||||
|
||||
public LocalDate getTermStart() { return termStart; }
|
||||
public void setTermStart(LocalDate termStart) { this.termStart = termStart; }
|
||||
|
||||
public LocalDate getTermEnd() { return termEnd; }
|
||||
public void setTermEnd(LocalDate termEnd) { this.termEnd = termEnd; }
|
||||
|
||||
public Boolean getIsCurrent() { return isCurrent; }
|
||||
public void setIsCurrent(Boolean isCurrent) { this.isCurrent = isCurrent; }
|
||||
|
||||
public UUID getElectedInAssemblyId() { return electedInAssemblyId; }
|
||||
public void setElectedInAssemblyId(UUID electedInAssemblyId) { this.electedInAssemblyId = electedInAssemblyId; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Board position definition (e.g., "1. Vorsitzender", "Kassenwart", "Präventionsbeauftragter").
|
||||
* Legal basis: §26 BGB (Vorstand), §30 BGB (Besonderer Vertreter), §23 KCanG (Präventionsbeauftragter).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "board_positions", indexes = {
|
||||
@Index(name = "idx_board_positions_club", columnList = "club_id"),
|
||||
@Index(name = "idx_board_positions_tenant", columnList = "tenant_id")
|
||||
})
|
||||
public class BoardPosition extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 100)
|
||||
private String title;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder = 0;
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = true;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public Integer getSortOrder() { return sortOrder; }
|
||||
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||
|
||||
public Boolean getIsActive() { return isActive; }
|
||||
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.EventType;
|
||||
import de.cannamanage.domain.enums.RecurrenceRule;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Club event entity — supports RSVP, recurring events, and iCal export.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "club_events", indexes = {
|
||||
@Index(name = "idx_club_events_tenant_start", columnList = "tenant_id, start_at"),
|
||||
@Index(name = "idx_club_events_type", columnList = "tenant_id, event_type"),
|
||||
@Index(name = "idx_club_events_club_id", columnList = "club_id")
|
||||
})
|
||||
public class ClubEvent extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "event_type", nullable = false, length = 50)
|
||||
private EventType eventType;
|
||||
|
||||
@Column(name = "start_at", nullable = false)
|
||||
private Instant startAt;
|
||||
|
||||
@Column(name = "end_at")
|
||||
private Instant endAt;
|
||||
|
||||
@Column(name = "location", length = 300)
|
||||
private String location;
|
||||
|
||||
@Column(name = "max_attendees")
|
||||
private Integer maxAttendees;
|
||||
|
||||
@Column(name = "is_recurring", nullable = false)
|
||||
private boolean recurring = false;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "recurrence_rule", length = 100)
|
||||
private RecurrenceRule recurrenceRule;
|
||||
|
||||
@Column(name = "recurrence_end_date")
|
||||
private LocalDate recurrenceEndDate;
|
||||
|
||||
@Column(name = "reminder_sent", nullable = false)
|
||||
private boolean reminderSent = false;
|
||||
|
||||
@Column(name = "created_by", nullable = false)
|
||||
private UUID createdBy;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<EventRsvp> rsvps = new ArrayList<>();
|
||||
|
||||
public ClubEvent() {}
|
||||
|
||||
public ClubEvent(UUID clubId, String title, String description, EventType eventType,
|
||||
Instant startAt, Instant endAt, String location, Integer maxAttendees,
|
||||
UUID createdBy) {
|
||||
this.clubId = clubId;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.eventType = eventType;
|
||||
this.startAt = startAt;
|
||||
this.endAt = endAt;
|
||||
this.location = location;
|
||||
this.maxAttendees = maxAttendees;
|
||||
this.createdBy = createdBy;
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public EventType getEventType() { return eventType; }
|
||||
public void setEventType(EventType eventType) { this.eventType = eventType; }
|
||||
|
||||
public Instant getStartAt() { return startAt; }
|
||||
public void setStartAt(Instant startAt) { this.startAt = startAt; }
|
||||
|
||||
public Instant getEndAt() { return endAt; }
|
||||
public void setEndAt(Instant endAt) { this.endAt = endAt; }
|
||||
|
||||
public String getLocation() { return location; }
|
||||
public void setLocation(String location) { this.location = location; }
|
||||
|
||||
public Integer getMaxAttendees() { return maxAttendees; }
|
||||
public void setMaxAttendees(Integer maxAttendees) { this.maxAttendees = maxAttendees; }
|
||||
|
||||
public boolean isRecurring() { return recurring; }
|
||||
public void setRecurring(boolean recurring) { this.recurring = recurring; }
|
||||
|
||||
public RecurrenceRule getRecurrenceRule() { return recurrenceRule; }
|
||||
public void setRecurrenceRule(RecurrenceRule recurrenceRule) { this.recurrenceRule = recurrenceRule; }
|
||||
|
||||
public LocalDate getRecurrenceEndDate() { return recurrenceEndDate; }
|
||||
public void setRecurrenceEndDate(LocalDate recurrenceEndDate) { this.recurrenceEndDate = recurrenceEndDate; }
|
||||
|
||||
public boolean isReminderSent() { return reminderSent; }
|
||||
public void setReminderSent(boolean reminderSent) { this.reminderSent = reminderSent; }
|
||||
|
||||
public UUID getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
||||
public List<EventRsvp> getRsvps() { return rsvps; }
|
||||
public void setRsvps(List<EventRsvp> rsvps) { this.rsvps = rsvps; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.ComplianceArea;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Tracks compliance deadlines with optional recurrence.
|
||||
* Powers the compliance dashboard traffic-light system.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "compliance_deadlines")
|
||||
public class ComplianceDeadline extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "area", nullable = false, length = 50)
|
||||
private ComplianceArea area;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Column(name = "description")
|
||||
private String description;
|
||||
|
||||
@Column(name = "due_date", nullable = false)
|
||||
private LocalDate dueDate;
|
||||
|
||||
@Column(name = "is_recurring")
|
||||
private Boolean isRecurring = false;
|
||||
|
||||
@Column(name = "recurrence_rule", length = 50)
|
||||
private String recurrenceRule;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private Instant completedAt;
|
||||
|
||||
@Column(name = "completed_by")
|
||||
private UUID completedBy;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public ComplianceArea getArea() { return area; }
|
||||
public void setArea(ComplianceArea area) { this.area = area; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public LocalDate getDueDate() { return dueDate; }
|
||||
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
|
||||
|
||||
public Boolean getIsRecurring() { return isRecurring; }
|
||||
public void setIsRecurring(Boolean recurring) { isRecurring = recurring; }
|
||||
|
||||
public String getRecurrenceRule() { return recurrenceRule; }
|
||||
public void setRecurrenceRule(String recurrenceRule) { this.recurrenceRule = recurrenceRule; }
|
||||
|
||||
public Instant getCompletedAt() { return completedAt; }
|
||||
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
|
||||
|
||||
public UUID getCompletedBy() { return completedBy; }
|
||||
public void setCompletedBy(UUID completedBy) { this.completedBy = completedBy; }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Sprint 10 — Saved CSV column mapping template for a club.
|
||||
* <p>
|
||||
* CSV bank exports have no standard layout: column order, delimiter, encoding,
|
||||
* date format, and decimal separator all vary by bank. Rather than asking the
|
||||
* admin to re-configure on every upload, mappings are saved per bank
|
||||
* (e.g. "Sparkasse Export", "DKB Online").
|
||||
* <p>
|
||||
* One mapping per club may be flagged as {@link #isDefault} — used to
|
||||
* pre-populate the upload wizard.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "csv_column_mappings")
|
||||
public class CsvColumnMapping extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
/** User-facing label, e.g. "Sparkasse Export" or "DKB Online". */
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
/** 0-based column index of the booking date. */
|
||||
@Column(name = "date_column", nullable = false)
|
||||
private Integer dateColumn;
|
||||
|
||||
/** 0-based column index of the amount field (sign convention: bank's own). */
|
||||
@Column(name = "amount_column", nullable = false)
|
||||
private Integer amountColumn;
|
||||
|
||||
@Column(name = "reference_column")
|
||||
private Integer referenceColumn;
|
||||
|
||||
@Column(name = "counterparty_column")
|
||||
private Integer counterpartyColumn;
|
||||
|
||||
@Column(name = "iban_column")
|
||||
private Integer ibanColumn;
|
||||
|
||||
@Column(name = "delimiter", nullable = false, length = 5)
|
||||
private String delimiter = ";";
|
||||
|
||||
/** Pattern compatible with {@code DateTimeFormatter.ofPattern}, e.g. {@code dd.MM.yyyy}. */
|
||||
@Column(name = "date_format", nullable = false, length = 20)
|
||||
private String dateFormat = "dd.MM.yyyy";
|
||||
|
||||
/** Single character — typically "," (German) or "." (English). */
|
||||
@Column(name = "decimal_separator", nullable = false, length = 1)
|
||||
private String decimalSeparator = ",";
|
||||
|
||||
@Column(name = "skip_header_rows", nullable = false)
|
||||
private Integer skipHeaderRows = 1;
|
||||
|
||||
/** Character set name, e.g. {@code ISO-8859-1} (German default), {@code UTF-8}, {@code windows-1252}. */
|
||||
@Column(name = "encoding", nullable = false, length = 20)
|
||||
private String encoding = "ISO-8859-1";
|
||||
|
||||
@Column(name = "is_default", nullable = false)
|
||||
private Boolean isDefault = false;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public Integer getDateColumn() { return dateColumn; }
|
||||
public void setDateColumn(Integer dateColumn) { this.dateColumn = dateColumn; }
|
||||
|
||||
public Integer getAmountColumn() { return amountColumn; }
|
||||
public void setAmountColumn(Integer amountColumn) { this.amountColumn = amountColumn; }
|
||||
|
||||
public Integer getReferenceColumn() { return referenceColumn; }
|
||||
public void setReferenceColumn(Integer referenceColumn) { this.referenceColumn = referenceColumn; }
|
||||
|
||||
public Integer getCounterpartyColumn() { return counterpartyColumn; }
|
||||
public void setCounterpartyColumn(Integer counterpartyColumn) { this.counterpartyColumn = counterpartyColumn; }
|
||||
|
||||
public Integer getIbanColumn() { return ibanColumn; }
|
||||
public void setIbanColumn(Integer ibanColumn) { this.ibanColumn = ibanColumn; }
|
||||
|
||||
public String getDelimiter() { return delimiter; }
|
||||
public void setDelimiter(String delimiter) { this.delimiter = delimiter; }
|
||||
|
||||
public String getDateFormat() { return dateFormat; }
|
||||
public void setDateFormat(String dateFormat) { this.dateFormat = dateFormat; }
|
||||
|
||||
public String getDecimalSeparator() { return decimalSeparator; }
|
||||
public void setDecimalSeparator(String decimalSeparator) { this.decimalSeparator = decimalSeparator; }
|
||||
|
||||
public Integer getSkipHeaderRows() { return skipHeaderRows; }
|
||||
public void setSkipHeaderRows(Integer skipHeaderRows) { this.skipHeaderRows = skipHeaderRows; }
|
||||
|
||||
public String getEncoding() { return encoding; }
|
||||
public void setEncoding(String encoding) { this.encoding = encoding; }
|
||||
|
||||
public Boolean getIsDefault() { return isDefault; }
|
||||
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Stores custom mail domain configuration for Enterprise tier clubs.
|
||||
* Verified via DNS TXT record: cannamanage-verify={verification_token}
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "custom_mail_domains")
|
||||
public class CustomMailDomain {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "tenant_id", nullable = false, unique = true)
|
||||
private UUID tenantId;
|
||||
|
||||
@Column(name = "from_address", nullable = false)
|
||||
private String fromAddress;
|
||||
|
||||
@Column(name = "domain", nullable = false)
|
||||
private String domain;
|
||||
|
||||
@Column(name = "verification_token", nullable = false, length = 64)
|
||||
private String verificationToken;
|
||||
|
||||
@Column(name = "verified", nullable = false)
|
||||
private boolean verified = false;
|
||||
|
||||
@Column(name = "verified_at")
|
||||
private Instant verifiedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public CustomMailDomain(UUID tenantId, String fromAddress, String domain, String verificationToken) {
|
||||
this.tenantId = tenantId;
|
||||
this.fromAddress = fromAddress;
|
||||
this.domain = domain;
|
||||
this.verificationToken = verificationToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.DestructionMethod;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Records cannabis destruction events per KCanG §22.
|
||||
* Immutable compliance record — never updated after creation.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "destruction_records")
|
||||
public class DestructionRecord extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "batch_id")
|
||||
private UUID batchId;
|
||||
|
||||
@Column(name = "amount_grams", nullable = false, precision = 8, scale = 2)
|
||||
private BigDecimal amountGrams;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "destruction_method", nullable = false, length = 50)
|
||||
private DestructionMethod destructionMethod;
|
||||
|
||||
@Column(name = "description")
|
||||
private String description;
|
||||
|
||||
@Column(name = "destroyed_at", nullable = false)
|
||||
private Instant destroyedAt;
|
||||
|
||||
@Column(name = "witnessed_by")
|
||||
private UUID witnessedBy;
|
||||
|
||||
@Column(name = "witness_name", length = 200)
|
||||
private String witnessName;
|
||||
|
||||
@Column(name = "recorded_by", nullable = false)
|
||||
private UUID recordedBy;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public UUID getBatchId() { return batchId; }
|
||||
public void setBatchId(UUID batchId) { this.batchId = batchId; }
|
||||
|
||||
public BigDecimal getAmountGrams() { return amountGrams; }
|
||||
public void setAmountGrams(BigDecimal amountGrams) { this.amountGrams = amountGrams; }
|
||||
|
||||
public DestructionMethod getDestructionMethod() { return destructionMethod; }
|
||||
public void setDestructionMethod(DestructionMethod destructionMethod) { this.destructionMethod = destructionMethod; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public Instant getDestroyedAt() { return destroyedAt; }
|
||||
public void setDestroyedAt(Instant destroyedAt) { this.destroyedAt = destroyedAt; }
|
||||
|
||||
public UUID getWitnessedBy() { return witnessedBy; }
|
||||
public void setWitnessedBy(UUID witnessedBy) { this.witnessedBy = witnessedBy; }
|
||||
|
||||
public String getWitnessName() { return witnessName; }
|
||||
public void setWitnessName(String witnessName) { this.witnessName = witnessName; }
|
||||
|
||||
public UUID getRecordedBy() { return recordedBy; }
|
||||
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Push notification device token — stores Web Push subscriptions and mobile push tokens.
|
||||
* A user can have multiple device tokens (multi-device support, max 10 per user).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "device_tokens", uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"user_id", "token"})
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DeviceToken extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "platform", nullable = false, length = 20)
|
||||
private DevicePlatform platform;
|
||||
|
||||
@Column(name = "token", nullable = false, columnDefinition = "TEXT")
|
||||
private String token;
|
||||
|
||||
@Column(name = "device_name", length = 100)
|
||||
private String deviceName;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private Instant lastUsedAt;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.DocumentAccessLevel;
|
||||
import de.cannamanage.domain.enums.DocumentCategory;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Club document entity for the document archive.
|
||||
* Legal basis: §22 KCanG (documentation requirements), §147 AO (retention).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "documents", indexes = {
|
||||
@Index(name = "idx_documents_club", columnList = "club_id"),
|
||||
@Index(name = "idx_documents_tenant", columnList = "tenant_id"),
|
||||
@Index(name = "idx_documents_category", columnList = "club_id, category")
|
||||
})
|
||||
public class Document extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "category", nullable = false, length = 50)
|
||||
private DocumentCategory category;
|
||||
|
||||
@Column(name = "filename", nullable = false, length = 255)
|
||||
private String filename;
|
||||
|
||||
@Column(name = "content_type", nullable = false, length = 100)
|
||||
private String contentType;
|
||||
|
||||
@Column(name = "file_size", nullable = false)
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "storage_path", nullable = false, length = 500)
|
||||
private String storagePath;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "access_level", nullable = false, length = 20)
|
||||
private DocumentAccessLevel accessLevel = DocumentAccessLevel.ALL_MEMBERS;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "uploaded_by", nullable = false)
|
||||
private UUID uploadedBy;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public DocumentCategory getCategory() { return category; }
|
||||
public void setCategory(DocumentCategory category) { this.category = category; }
|
||||
|
||||
public String getFilename() { return filename; }
|
||||
public void setFilename(String filename) { this.filename = filename; }
|
||||
|
||||
public String getContentType() { return contentType; }
|
||||
public void setContentType(String contentType) { this.contentType = contentType; }
|
||||
|
||||
public Long getFileSize() { return fileSize; }
|
||||
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
|
||||
|
||||
public String getStoragePath() { return storagePath; }
|
||||
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
|
||||
|
||||
public DocumentAccessLevel getAccessLevel() { return accessLevel; }
|
||||
public void setAccessLevel(DocumentAccessLevel accessLevel) { this.accessLevel = accessLevel; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public UUID getUploadedBy() { return uploadedBy; }
|
||||
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.RsvpStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Event RSVP entity — tracks member attendance responses.
|
||||
* Unique constraint on (event_id, member_id) ensures one RSVP per member per event.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "event_rsvps",
|
||||
uniqueConstraints = @UniqueConstraint(
|
||||
name = "uq_event_rsvps_event_member",
|
||||
columnNames = {"event_id", "member_id"}
|
||||
),
|
||||
indexes = {
|
||||
@Index(name = "idx_event_rsvps_event", columnList = "event_id"),
|
||||
@Index(name = "idx_event_rsvps_member", columnList = "member_id")
|
||||
}
|
||||
)
|
||||
public class EventRsvp extends AbstractTenantEntity {
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "event_id", nullable = false)
|
||||
private ClubEvent event;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private UUID memberId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private RsvpStatus status;
|
||||
|
||||
@Column(name = "responded_at", nullable = false)
|
||||
private Instant respondedAt;
|
||||
|
||||
public EventRsvp() {}
|
||||
|
||||
public EventRsvp(ClubEvent event, UUID memberId, RsvpStatus status) {
|
||||
this.event = event;
|
||||
this.memberId = memberId;
|
||||
this.status = status;
|
||||
this.respondedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
|
||||
public ClubEvent getEvent() { return event; }
|
||||
public void setEvent(ClubEvent event) { this.event = event; }
|
||||
|
||||
public UUID getMemberId() { return memberId; }
|
||||
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||
|
||||
public RsvpStatus getStatus() { return status; }
|
||||
public void setStatus(RsvpStatus status) { this.status = status; }
|
||||
|
||||
public Instant getRespondedAt() { return respondedAt; }
|
||||
public void setRespondedAt(Instant respondedAt) { this.respondedAt = respondedAt; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Fee schedule (Beitragsordnung) — defines a named fee tier for the club.
|
||||
* Never hard-deleted; set isActive=false to deactivate.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "fee_schedules")
|
||||
public class FeeSchedule extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Integer amountCents;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "interval", nullable = false, length = 20)
|
||||
private FeeInterval interval;
|
||||
|
||||
@Column(name = "is_default")
|
||||
private Boolean isDefault = false;
|
||||
|
||||
@Column(name = "is_active")
|
||||
private Boolean isActive = true;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreateFee() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdateFee() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public Integer getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public FeeInterval getInterval() { return interval; }
|
||||
public void setInterval(FeeInterval interval) { this.interval = interval; }
|
||||
|
||||
public Boolean getIsDefault() { return isDefault; }
|
||||
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
|
||||
|
||||
public Boolean getIsActive() { return isActive; }
|
||||
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.ForumTargetType;
|
||||
import de.cannamanage.domain.enums.ReactionType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Forum reaction entity — one reaction per user per target (topic or reply).
|
||||
* Toggle behavior: clicking again removes the reaction.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "forum_reactions", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_forum_reactions_target_user",
|
||||
columnNames = {"target_type", "target_id", "user_id"})
|
||||
}, indexes = {
|
||||
@Index(name = "idx_forum_reactions_target", columnList = "target_type, target_id")
|
||||
})
|
||||
public class ForumReaction {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Column(name = "id", nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "target_type", nullable = false, length = 10)
|
||||
private ForumTargetType targetType;
|
||||
|
||||
@Column(name = "target_id", nullable = false)
|
||||
private UUID targetId;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reaction_type", nullable = false, length = 20)
|
||||
private ReactionType reactionType;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public ForumReaction() {}
|
||||
|
||||
public ForumReaction(ForumTargetType targetType, UUID targetId, UUID userId, ReactionType reactionType) {
|
||||
this.targetType = targetType;
|
||||
this.targetId = targetId;
|
||||
this.userId = userId;
|
||||
this.reactionType = reactionType;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public ForumTargetType getTargetType() { return targetType; }
|
||||
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
|
||||
|
||||
public UUID getTargetId() { return targetId; }
|
||||
public void setTargetId(UUID targetId) { this.targetId = targetId; }
|
||||
|
||||
public UUID getUserId() { return userId; }
|
||||
public void setUserId(UUID userId) { this.userId = userId; }
|
||||
|
||||
public ReactionType getReactionType() { return reactionType; }
|
||||
public void setReactionType(ReactionType reactionType) { this.reactionType = reactionType; }
|
||||
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Forum reply entity — a response to a forum topic.
|
||||
* Content stored as HTML. Replies can be edited within a 60-minute window.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "forum_replies", indexes = {
|
||||
@Index(name = "idx_forum_replies_topic_id", columnList = "topic_id"),
|
||||
@Index(name = "idx_forum_replies_tenant_id", columnList = "tenant_id")
|
||||
})
|
||||
public class ForumReply extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "topic_id", nullable = false)
|
||||
private UUID topicId;
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Column(name = "author_id", nullable = false)
|
||||
private UUID authorId;
|
||||
|
||||
@Column(name = "is_edited", nullable = false)
|
||||
private boolean edited = false;
|
||||
|
||||
@Column(name = "edited_at")
|
||||
private Instant editedAt;
|
||||
|
||||
public ForumReply() {}
|
||||
|
||||
public ForumReply(UUID topicId, UUID clubId, String content, UUID authorId) {
|
||||
this.topicId = topicId;
|
||||
this.clubId = clubId;
|
||||
this.content = content;
|
||||
this.authorId = authorId;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getTopicId() { return topicId; }
|
||||
public void setTopicId(UUID topicId) { this.topicId = topicId; }
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
|
||||
public UUID getAuthorId() { return authorId; }
|
||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||
|
||||
public boolean isEdited() { return edited; }
|
||||
public void setEdited(boolean edited) { this.edited = edited; }
|
||||
|
||||
public Instant getEditedAt() { return editedAt; }
|
||||
public void setEditedAt(Instant editedAt) { this.editedAt = editedAt; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.ForumTargetType;
|
||||
import de.cannamanage.domain.enums.ReportStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Forum report entity — allows members to report inappropriate content.
|
||||
* Reporter identity is protected: reporterId is NOT exposed in public DTOs (only visible to moderators).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "forum_reports", indexes = {
|
||||
@Index(name = "idx_forum_reports_club_status", columnList = "club_id, status"),
|
||||
@Index(name = "idx_forum_reports_tenant_id", columnList = "tenant_id")
|
||||
})
|
||||
public class ForumReport extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "target_type", nullable = false, length = 10)
|
||||
private ForumTargetType targetType;
|
||||
|
||||
@Column(name = "target_id", nullable = false)
|
||||
private UUID targetId;
|
||||
|
||||
@Column(name = "reporter_id", nullable = false)
|
||||
private UUID reporterId;
|
||||
|
||||
@Column(name = "reason", nullable = false, columnDefinition = "TEXT")
|
||||
private String reason;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
private ReportStatus status = ReportStatus.OPEN;
|
||||
|
||||
@Column(name = "reviewed_by")
|
||||
private UUID reviewedBy;
|
||||
|
||||
@Column(name = "reviewed_at")
|
||||
private Instant reviewedAt;
|
||||
|
||||
public ForumReport() {}
|
||||
|
||||
public ForumReport(UUID clubId, ForumTargetType targetType, UUID targetId, UUID reporterId, String reason) {
|
||||
this.clubId = clubId;
|
||||
this.targetType = targetType;
|
||||
this.targetId = targetId;
|
||||
this.reporterId = reporterId;
|
||||
this.reason = reason;
|
||||
this.status = ReportStatus.OPEN;
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public ForumTargetType getTargetType() { return targetType; }
|
||||
public void setTargetType(ForumTargetType targetType) { this.targetType = targetType; }
|
||||
|
||||
public UUID getTargetId() { return targetId; }
|
||||
public void setTargetId(UUID targetId) { this.targetId = targetId; }
|
||||
|
||||
public UUID getReporterId() { return reporterId; }
|
||||
public void setReporterId(UUID reporterId) { this.reporterId = reporterId; }
|
||||
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
|
||||
public ReportStatus getStatus() { return status; }
|
||||
public void setStatus(ReportStatus status) { this.status = status; }
|
||||
|
||||
public UUID getReviewedBy() { return reviewedBy; }
|
||||
public void setReviewedBy(UUID reviewedBy) { this.reviewedBy = reviewedBy; }
|
||||
|
||||
public Instant getReviewedAt() { return reviewedAt; }
|
||||
public void setReviewedAt(Instant reviewedAt) { this.reviewedAt = reviewedAt; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Forum topic entity — club-scoped discussion thread.
|
||||
* Content is stored as HTML (from Tiptap rich text editor).
|
||||
* Extends AbstractTenantEntity for automatic tenant isolation.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "forum_topics", indexes = {
|
||||
@Index(name = "idx_forum_topics_club_id", columnList = "club_id"),
|
||||
@Index(name = "idx_forum_topics_tenant_id", columnList = "tenant_id")
|
||||
})
|
||||
public class ForumTopic extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Column(name = "author_id", nullable = false)
|
||||
private UUID authorId;
|
||||
|
||||
@Column(name = "is_locked", nullable = false)
|
||||
private boolean locked = false;
|
||||
|
||||
@Column(name = "is_pinned", nullable = false)
|
||||
private boolean pinned = false;
|
||||
|
||||
@Column(name = "reply_count", nullable = false)
|
||||
private int replyCount = 0;
|
||||
|
||||
@Column(name = "last_reply_at")
|
||||
private Instant lastReplyAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
public ForumTopic() {}
|
||||
|
||||
public ForumTopic(UUID clubId, String title, String content, UUID authorId) {
|
||||
this.clubId = clubId;
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.authorId = authorId;
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@Override
|
||||
void onCreate() {
|
||||
super.onCreate();
|
||||
if (this.updatedAt == null) {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
|
||||
public UUID getAuthorId() { return authorId; }
|
||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||
|
||||
public boolean isLocked() { return locked; }
|
||||
public void setLocked(boolean locked) { this.locked = locked; }
|
||||
|
||||
public boolean isPinned() { return pinned; }
|
||||
public void setPinned(boolean pinned) { this.pinned = pinned; }
|
||||
|
||||
public int getReplyCount() { return replyCount; }
|
||||
public void setReplyCount(int replyCount) { this.replyCount = replyCount; }
|
||||
|
||||
public Instant getLastReplyAt() { return lastReplyAt; }
|
||||
public void setLastReplyAt(Instant lastReplyAt) { this.lastReplyAt = lastReplyAt; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.ExportFormat;
|
||||
import de.cannamanage.domain.enums.ReportType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Metadata for generated reports. The actual file is stored on disk.
|
||||
* Provides audit trail of all reports generated per tenant.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "generated_reports")
|
||||
public class GeneratedReport extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false, updatable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "report_type", nullable = false, length = 50)
|
||||
private ReportType reportType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "report_format", nullable = false, length = 10)
|
||||
private ExportFormat reportFormat;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 300)
|
||||
private String title;
|
||||
|
||||
@Column(name = "file_size")
|
||||
private Long fileSize;
|
||||
|
||||
@Column(name = "storage_path", length = 500)
|
||||
private String storagePath;
|
||||
|
||||
@Column(name = "parameters", columnDefinition = "jsonb")
|
||||
private String parameters; // JSON string
|
||||
|
||||
@Column(name = "generated_by", nullable = false)
|
||||
private UUID generatedBy;
|
||||
|
||||
@Column(name = "generated_at")
|
||||
private Instant generatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreateReport() {
|
||||
if (this.generatedAt == null) {
|
||||
this.generatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public ReportType getReportType() { return reportType; }
|
||||
public void setReportType(ReportType reportType) { this.reportType = reportType; }
|
||||
|
||||
public ExportFormat getReportFormat() { return reportFormat; }
|
||||
public void setReportFormat(ExportFormat reportFormat) { this.reportFormat = reportFormat; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public Long getFileSize() { return fileSize; }
|
||||
public void setFileSize(Long fileSize) { this.fileSize = fileSize; }
|
||||
|
||||
public String getStoragePath() { return storagePath; }
|
||||
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
|
||||
|
||||
public String getParameters() { return parameters; }
|
||||
public void setParameters(String parameters) { this.parameters = parameters; }
|
||||
|
||||
public UUID getGeneratedBy() { return generatedBy; }
|
||||
public void setGeneratedBy(UUID generatedBy) { this.generatedBy = generatedBy; }
|
||||
|
||||
public Instant getGeneratedAt() { return generatedAt; }
|
||||
public void setGeneratedAt(Instant generatedAt) { this.generatedAt = generatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.InfoBoardCategory;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Info board post entity — club-scoped announcements (Schwarzes Brett).
|
||||
* Content is stored as HTML (from Tiptap rich text editor).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "info_board_posts", indexes = {
|
||||
@Index(name = "idx_info_board_posts_club_id", columnList = "club_id"),
|
||||
@Index(name = "idx_info_board_posts_category", columnList = "category"),
|
||||
@Index(name = "idx_info_board_posts_tenant", columnList = "tenant_id")
|
||||
})
|
||||
public class InfoBoardPost extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Column(name = "title", nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "category", nullable = false, length = 50)
|
||||
private InfoBoardCategory category;
|
||||
|
||||
@Column(name = "is_pinned", nullable = false)
|
||||
private boolean pinned = false;
|
||||
|
||||
@Column(name = "is_archived", nullable = false)
|
||||
private boolean archived = false;
|
||||
|
||||
@Column(name = "author_id", nullable = false)
|
||||
private UUID authorId;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<PostAttachment> attachments = new ArrayList<>();
|
||||
|
||||
public InfoBoardPost() {}
|
||||
|
||||
public InfoBoardPost(UUID clubId, String title, String content, InfoBoardCategory category, UUID authorId) {
|
||||
this.clubId = clubId;
|
||||
this.title = title;
|
||||
this.content = content;
|
||||
this.category = category;
|
||||
this.authorId = authorId;
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@Override
|
||||
void onCreate() {
|
||||
super.onCreate();
|
||||
if (this.updatedAt == null) {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
|
||||
public InfoBoardCategory getCategory() { return category; }
|
||||
public void setCategory(InfoBoardCategory category) { this.category = category; }
|
||||
|
||||
public boolean isPinned() { return pinned; }
|
||||
public void setPinned(boolean pinned) { this.pinned = pinned; }
|
||||
|
||||
public boolean isArchived() { return archived; }
|
||||
public void setArchived(boolean archived) { this.archived = archived; }
|
||||
|
||||
public UUID getAuthorId() { return authorId; }
|
||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
||||
public List<PostAttachment> getAttachments() { return attachments; }
|
||||
public void setAttachments(List<PostAttachment> attachments) { this.attachments = attachments; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.cannamanage.domain.entity;
|
||||
|
||||
import de.cannamanage.domain.enums.TransactionType;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Kassenbuch entry — append-only per §147 AO (Aufbewahrungspflicht).
|
||||
* NO update, NO delete. Corrections are done via compensating entries.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "ledger_entries")
|
||||
public class LedgerEntry extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "transaction_type", nullable = false, length = 10)
|
||||
private TransactionType transactionType;
|
||||
|
||||
@Column(name = "category", nullable = false, length = 50)
|
||||
private String category;
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Integer amountCents;
|
||||
|
||||
@Column(name = "description", nullable = false, length = 500)
|
||||
private String description;
|
||||
|
||||
@Column(name = "reference", length = 200)
|
||||
private String reference;
|
||||
|
||||
@Column(name = "payment_id")
|
||||
private UUID paymentId;
|
||||
|
||||
@Column(name = "recorded_by", nullable = false)
|
||||
private UUID recordedBy;
|
||||
|
||||
@Column(name = "transaction_date", nullable = false)
|
||||
private LocalDate transactionDate;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
public TransactionType getTransactionType() { return transactionType; }
|
||||
public void setTransactionType(TransactionType transactionType) { this.transactionType = transactionType; }
|
||||
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
|
||||
public Integer getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public String getReference() { return reference; }
|
||||
public void setReference(String reference) { this.reference = reference; }
|
||||
|
||||
public UUID getPaymentId() { return paymentId; }
|
||||
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
|
||||
|
||||
public UUID getRecordedBy() { return recordedBy; }
|
||||
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
|
||||
|
||||
public LocalDate getTransactionDate() { return transactionDate; }
|
||||
public void setTransactionDate(LocalDate transactionDate) { this.transactionDate = transactionDate; }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.cannamanage.domain.entity;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -15,6 +16,9 @@ import java.util.UUID;
|
||||
)
|
||||
public class Member extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "user_id")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "club_id", nullable = false)
|
||||
private UUID clubId;
|
||||
|
||||
@@ -46,6 +50,20 @@ public class Member extends AbstractTenantEntity {
|
||||
@Column(name = "prevention_officer", nullable = false)
|
||||
private boolean preventionOfficer = false;
|
||||
|
||||
/**
|
||||
* Sprint 10 — Member's IBAN, used by the bank statement matching engine.
|
||||
* Nullable: only populated after the member explicitly grants BANK_DATA consent.
|
||||
*/
|
||||
@Column(name = "iban", length = 34)
|
||||
private String iban;
|
||||
|
||||
/** Sprint 10 — Timestamp when BANK_DATA consent was granted for this IBAN. */
|
||||
@Column(name = "iban_consent_date")
|
||||
private Instant ibanConsentDate;
|
||||
|
||||
public UUID getUserId() { return userId; }
|
||||
public void setUserId(UUID userId) { this.userId = userId; }
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||
|
||||
@@ -75,4 +93,10 @@ public class Member extends AbstractTenantEntity {
|
||||
|
||||
public boolean isPreventionOfficer() { return preventionOfficer; }
|
||||
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
|
||||
|
||||
public String getIban() { return iban; }
|
||||
public void setIban(String iban) { this.iban = iban; }
|
||||
|
||||
public Instant getIbanConsentDate() { return ibanConsentDate; }
|
||||
public void setIbanConsentDate(Instant ibanConsentDate) { this.ibanConsentDate = ibanConsentDate; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user