feat(sprint7): Phase 4 — Integration (SMTP, tier enforcement, WebSocket)
Phase 4 implementation: - 4.1 IONOS SMTP email configuration (production + docker profiles) - 4.2 Portal navigation update (info board, events, forum links) - 4.3 Tier enforcement: PlanTierService (forum=Pro+, info board limits) - 4.4 WebSocket real-time updates (WebSocketEventPublisher) - 4.5 EmailService: notification, event reminder, info board templates + rate limiting - 4.6 Enterprise custom FROM: CustomMailDomain entity, DNS verification, controller New files: - PlanTierService: tier checks for forum/info board/enterprise features - NotificationDispatchService: EMAIL channel dispatch via preferences - WebSocketEventPublisher: STOMP topic push for forum/info board/events - CustomMailDomainService: DNS TXT record verification for custom FROM - MailSettingsController: Enterprise custom domain API endpoints - CustomMailDomain entity + repository - V16 migration: email dispatch index - V17 migration: custom_mail_domains table - Frontend: use-forum-subscription + use-info-board-subscription hooks - Portal navbar: added info board, events, forum navigation items - i18n: added portal nav translations (de + en) Also fixed pre-existing Phase 2.5/3 compilation issues: - Member entity: added userId field - AuditService: added convenience overloads (logEvent, 4-param log) - AuditEventType: added INFO_BOARD_POST_UPDATED, INFO_BOARD_POST_DELETED - QuotaViolationCode: added TIER_UPGRADE_REQUIRED - StaffPermissionChecker: added requirePermission(UserDetails, ...) - TenantContext: added getCurrentTenantId() alias - MemberRepository: added findByUserId, findByClubId, findAllByClubId - EmailServiceTest: updated for new constructor signature
This commit is contained in:
+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
|
||||
) {}
|
||||
}
|
||||
@@ -54,4 +54,35 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user