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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,18 @@ 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:}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user