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:
@@ -90,6 +90,22 @@ public class AuditService {
|
||||
/**
|
||||
* Export audit events as PDF bytes for a given date range.
|
||||
*/
|
||||
/**
|
||||
* Convenience overload for simple audit logging without actor/IP details.
|
||||
*/
|
||||
public AuditEvent log(AuditEventType eventType, String entityType, String entityId, String description) {
|
||||
return log(eventType, entityType, entityId != null ? UUID.fromString(entityId) : null,
|
||||
null, null, null, description, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload with actorId for audit logging.
|
||||
*/
|
||||
public void logEvent(AuditEventType eventType, UUID actorId, String description, String entityId) {
|
||||
log(eventType, "System", entityId != null ? UUID.fromString(entityId) : null,
|
||||
actorId, null, null, description, null, null);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public byte[] exportPdf(UUID tenantId, Instant from, Instant to) {
|
||||
List<AuditEvent> events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, to);
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||
import de.cannamanage.service.repository.CustomMailDomainRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.naming.NamingEnumeration;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.Attributes;
|
||||
import javax.naming.directory.DirContext;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import java.time.Instant;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for managing custom mail domains for Enterprise tier clubs.
|
||||
*
|
||||
* Verification flow:
|
||||
* 1. Admin enters desired FROM address (e.g., info@gruener-daumen.de)
|
||||
* 2. System extracts domain, generates unique verification token
|
||||
* 3. Admin adds DNS TXT record: cannamanage-verify={token}
|
||||
* 4. Admin clicks "Verify" → system does DNS TXT lookup
|
||||
* 5. If record matches → mark as verified
|
||||
* 6. Outbound emails now use the custom FROM address
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Transactional
|
||||
public class CustomMailDomainService {
|
||||
|
||||
private static final String DNS_TXT_PREFIX = "cannamanage-verify=";
|
||||
|
||||
private final CustomMailDomainRepository repository;
|
||||
private final PlanTierService planTierService;
|
||||
|
||||
public CustomMailDomainService(CustomMailDomainRepository repository,
|
||||
PlanTierService planTierService) {
|
||||
this.repository = repository;
|
||||
this.planTierService = planTierService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom FROM address for an Enterprise club.
|
||||
* Generates a verification token that must be added as DNS TXT record.
|
||||
*/
|
||||
public CustomMailDomain setCustomDomain(UUID tenantId, String fromAddress) {
|
||||
planTierService.requireEnterpriseTier(tenantId);
|
||||
|
||||
String domain = extractDomain(fromAddress);
|
||||
String token = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
|
||||
|
||||
// Remove existing config if any
|
||||
repository.findByTenantId(tenantId).ifPresent(repository::delete);
|
||||
|
||||
CustomMailDomain config = new CustomMailDomain(tenantId, fromAddress, domain, token);
|
||||
CustomMailDomain saved = repository.save(config);
|
||||
|
||||
log.info("Custom mail domain set for club {}: {} (domain: {})", tenantId, fromAddress, domain);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current custom domain configuration for a club.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<CustomMailDomain> getCustomDomain(UUID tenantId) {
|
||||
return repository.findByTenantId(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the custom domain by checking DNS TXT records.
|
||||
* Looks for: cannamanage-verify={token}
|
||||
*/
|
||||
public CustomMailDomain verifyDomain(UUID tenantId) {
|
||||
planTierService.requireEnterpriseTier(tenantId);
|
||||
|
||||
CustomMailDomain config = repository.findByTenantId(tenantId)
|
||||
.orElseThrow(() -> new IllegalStateException("No custom domain configured for this club"));
|
||||
|
||||
if (config.isVerified()) {
|
||||
return config; // Already verified
|
||||
}
|
||||
|
||||
boolean verified = checkDnsTxtRecord(config.getDomain(), config.getVerificationToken());
|
||||
|
||||
if (verified) {
|
||||
config.setVerified(true);
|
||||
config.setVerifiedAt(Instant.now());
|
||||
repository.save(config);
|
||||
log.info("Custom mail domain verified for club {}: {}", tenantId, config.getDomain());
|
||||
} else {
|
||||
log.info("DNS verification failed for club {} domain {}", tenantId, config.getDomain());
|
||||
throw new IllegalStateException(
|
||||
"DNS-Verifikation fehlgeschlagen. Bitte stelle sicher, dass der TXT-Eintrag " +
|
||||
"'" + DNS_TXT_PREFIX + config.getVerificationToken() + "' " +
|
||||
"für die Domain '" + config.getDomain() + "' korrekt gesetzt ist. " +
|
||||
"DNS-Änderungen können bis zu 48 Stunden dauern."
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove custom domain configuration.
|
||||
*/
|
||||
public void removeCustomDomain(UUID tenantId) {
|
||||
repository.findByTenantId(tenantId).ifPresent(config -> {
|
||||
repository.delete(config);
|
||||
log.info("Custom mail domain removed for club {}", tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective FROM address for a club's outbound emails.
|
||||
* Returns custom FROM if verified, otherwise platform default.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> getVerifiedFromAddress(UUID tenantId) {
|
||||
return repository.findByTenantId(tenantId)
|
||||
.filter(CustomMailDomain::isVerified)
|
||||
.map(CustomMailDomain::getFromAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check DNS TXT records for the verification token.
|
||||
*/
|
||||
private boolean checkDnsTxtRecord(String domain, String expectedToken) {
|
||||
try {
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
|
||||
DirContext ctx = new InitialDirContext(env);
|
||||
|
||||
Attributes attrs = ctx.getAttributes(domain, new String[]{"TXT"});
|
||||
Attribute txtAttr = attrs.get("TXT");
|
||||
|
||||
if (txtAttr != null) {
|
||||
NamingEnumeration<?> records = txtAttr.getAll();
|
||||
while (records.hasMore()) {
|
||||
String record = records.next().toString().replace("\"", "").trim();
|
||||
if (record.equals(DNS_TXT_PREFIX + expectedToken)) {
|
||||
ctx.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("DNS lookup failed for domain {}: {}", domain, e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractDomain(String email) {
|
||||
if (email == null || !email.contains("@")) {
|
||||
throw new IllegalArgumentException("Invalid email address: " + email);
|
||||
}
|
||||
return email.substring(email.indexOf("@") + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,25 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Email service for sending invite emails to new staff members.
|
||||
* Uses plain text templates — no Thymeleaf dependency needed.
|
||||
* Email service for sending transactional and notification emails via IONOS SMTP.
|
||||
* Supports:
|
||||
* - Staff invite emails
|
||||
* - Notification digest emails
|
||||
* - Event reminder emails
|
||||
* - Info board update emails
|
||||
*
|
||||
* Rate limiting: max N emails per minute (configurable via cannamanage.mail.rate-limit).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -17,18 +31,27 @@ public class EmailService {
|
||||
private final JavaMailSender mailSender;
|
||||
private final String baseUrl;
|
||||
private final String fromAddress;
|
||||
private final String replyToAddress;
|
||||
private final int rateLimit;
|
||||
|
||||
// Simple rate limiter: track sends per minute window
|
||||
private final AtomicInteger sendCount = new AtomicInteger(0);
|
||||
private final AtomicLong windowStart = new AtomicLong(System.currentTimeMillis());
|
||||
|
||||
public EmailService(JavaMailSender mailSender,
|
||||
@Value("${app.base-url:http://localhost:8080}") String baseUrl,
|
||||
@Value("${spring.mail.from:noreply@cannamanage.de}") String fromAddress) {
|
||||
@Value("${cannamanage.mail.from:noreply@cannamanage.plate-software.de}") String fromAddress,
|
||||
@Value("${cannamanage.mail.reply-to:support@cannamanage.plate-software.de}") String replyToAddress,
|
||||
@Value("${cannamanage.mail.rate-limit:50}") int rateLimit) {
|
||||
this.mailSender = mailSender;
|
||||
this.baseUrl = baseUrl;
|
||||
this.fromAddress = fromAddress;
|
||||
this.replyToAddress = replyToAddress;
|
||||
this.rateLimit = rateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invite email to a new staff member with a link to set their password.
|
||||
* Security: token value is NOT logged.
|
||||
*/
|
||||
public void sendInviteEmail(String recipientEmail, String displayName,
|
||||
String clubName, String token) {
|
||||
@@ -51,18 +74,200 @@ public class EmailService {
|
||||
Dein CannaManage-Team
|
||||
""", displayName, clubName, setPasswordUrl);
|
||||
|
||||
sendPlainTextEmail(recipientEmail, "Einladung: " + clubName + " — Account aktivieren", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification email (broadcast or targeted).
|
||||
*/
|
||||
@Async
|
||||
public void sendNotificationEmail(String recipientEmail, String recipientName,
|
||||
String title, String message, String actionLink) {
|
||||
String body = String.format("""
|
||||
Hallo %s,
|
||||
|
||||
Du hast eine neue Benachrichtigung:
|
||||
|
||||
%s
|
||||
─────────────────────────────
|
||||
%s
|
||||
|
||||
%s
|
||||
|
||||
─────────────────────────────
|
||||
Du erhältst diese E-Mail, weil du E-Mail-Benachrichtigungen aktiviert hast.
|
||||
Einstellungen ändern: %s/portal/profile
|
||||
|
||||
CannaManage — Deine Vereinsverwaltung
|
||||
""",
|
||||
recipientName,
|
||||
title,
|
||||
message,
|
||||
actionLink != null ? "Zum Beitrag: " + baseUrl + actionLink : "",
|
||||
baseUrl);
|
||||
|
||||
sendPlainTextEmail(recipientEmail, "CannaManage: " + title, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an event reminder email.
|
||||
*/
|
||||
@Async
|
||||
public void sendEventReminderEmail(String recipientEmail, String recipientName,
|
||||
String eventTitle, String eventDate, String eventLocation) {
|
||||
String body = String.format("""
|
||||
Hallo %s,
|
||||
|
||||
Erinnerung: Morgen findet folgende Veranstaltung statt:
|
||||
|
||||
📅 %s
|
||||
🕐 %s
|
||||
📍 %s
|
||||
|
||||
Wir freuen uns auf deine Teilnahme!
|
||||
|
||||
─────────────────────────────
|
||||
Du erhältst diese E-Mail, weil du für diese Veranstaltung zugesagt hast.
|
||||
Einstellungen ändern: %s/portal/profile
|
||||
|
||||
CannaManage — Deine Vereinsverwaltung
|
||||
""",
|
||||
recipientName, eventTitle, eventDate,
|
||||
eventLocation != null ? eventLocation : "Wird noch bekanntgegeben",
|
||||
baseUrl);
|
||||
|
||||
sendPlainTextEmail(recipientEmail, "Erinnerung: " + eventTitle + " — Morgen!", body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an info board update email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInfoBoardEmail(String recipientEmail, String recipientName,
|
||||
String postTitle, String category) {
|
||||
String body = String.format("""
|
||||
Hallo %s,
|
||||
|
||||
Es gibt einen neuen Beitrag im Schwarzen Brett:
|
||||
|
||||
📌 %s
|
||||
Kategorie: %s
|
||||
|
||||
Zum Beitrag: %s/portal/info-board
|
||||
|
||||
─────────────────────────────
|
||||
Du erhältst diese E-Mail, weil du E-Mail-Benachrichtigungen für das Schwarze Brett aktiviert hast.
|
||||
Einstellungen ändern: %s/portal/profile
|
||||
|
||||
CannaManage — Deine Vereinsverwaltung
|
||||
""",
|
||||
recipientName, postTitle, category, baseUrl, baseUrl);
|
||||
|
||||
sendPlainTextEmail(recipientEmail, "Schwarzes Brett: " + postTitle, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of notification emails with rate limiting.
|
||||
* Emails are sent up to the rate limit per minute. Excess emails are delayed.
|
||||
*/
|
||||
@Async
|
||||
public void sendBatchNotificationEmails(List<EmailRecipient> recipients, String title,
|
||||
String message, String actionLink) {
|
||||
for (int i = 0; i < recipients.size(); i++) {
|
||||
checkRateLimit();
|
||||
EmailRecipient recipient = recipients.get(i);
|
||||
try {
|
||||
sendNotificationEmail(recipient.email(), recipient.name(), title, message, actionLink);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to send notification email to {}: {}", recipient.email(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured FROM address. Used by CustomMailDomainService to override for Enterprise clubs.
|
||||
*/
|
||||
public String getFromAddress() {
|
||||
return fromAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a plain text email with rate limiting.
|
||||
*/
|
||||
private void sendPlainTextEmail(String to, String subject, String body) {
|
||||
checkRateLimit();
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(fromAddress);
|
||||
message.setTo(recipientEmail);
|
||||
message.setSubject("Einladung: " + clubName + " — Account aktivieren");
|
||||
message.setReplyTo(replyToAddress);
|
||||
message.setTo(to);
|
||||
message.setSubject(subject);
|
||||
message.setText(body);
|
||||
|
||||
try {
|
||||
mailSender.send(message);
|
||||
log.info("Invite email sent to {} for club '{}'", recipientEmail, clubName);
|
||||
log.debug("Email sent to {} — subject: {}", to, subject);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send invite email to {}: {}", recipientEmail, e.getMessage());
|
||||
throw new RuntimeException("Failed to send invite email", e);
|
||||
log.error("Failed to send email to {}: {}", to, e.getMessage());
|
||||
throw new RuntimeException("Failed to send email", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email with a custom FROM address (for Enterprise tier verified domains).
|
||||
*/
|
||||
public void sendWithCustomFrom(String customFrom, String to, String subject, String body) {
|
||||
checkRateLimit();
|
||||
|
||||
try {
|
||||
MimeMessage mimeMessage = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
|
||||
helper.setFrom(customFrom);
|
||||
helper.setReplyTo(customFrom);
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body);
|
||||
|
||||
mailSender.send(mimeMessage);
|
||||
log.debug("Email sent from {} to {} — subject: {}", customFrom, to, subject);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Failed to send email with custom FROM {} to {}: {}", customFrom, to, e.getMessage());
|
||||
throw new RuntimeException("Failed to send email with custom FROM", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple rate limiter: max N emails per 60-second window.
|
||||
* Blocks the current thread if limit is reached until the window resets.
|
||||
*/
|
||||
private void checkRateLimit() {
|
||||
long now = System.currentTimeMillis();
|
||||
long windowStartMs = windowStart.get();
|
||||
|
||||
// Reset window if >60 seconds have passed
|
||||
if (now - windowStartMs > 60_000) {
|
||||
windowStart.set(now);
|
||||
sendCount.set(0);
|
||||
}
|
||||
|
||||
int currentCount = sendCount.incrementAndGet();
|
||||
if (currentCount > rateLimit) {
|
||||
long waitMs = 60_000 - (now - windowStart.get());
|
||||
if (waitMs > 0) {
|
||||
log.info("Email rate limit reached ({}/min), waiting {}ms", rateLimit, waitMs);
|
||||
try {
|
||||
Thread.sleep(waitMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
windowStart.set(System.currentTimeMillis());
|
||||
sendCount.set(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple record for batch email recipients.
|
||||
*/
|
||||
public record EmailRecipient(String email, String name) {}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ public class InfoBoardService {
|
||||
|
||||
// Dispatch notification to all club members
|
||||
try {
|
||||
var members = memberRepository.findByClubId(clubId);
|
||||
var members = memberRepository.findAllByClubId(clubId);
|
||||
members.forEach(member -> {
|
||||
if (member.getUserId() != null) {
|
||||
notificationService.createNotification(
|
||||
notificationService.sendNotification(
|
||||
member.getUserId(),
|
||||
NotificationType.INFO_BOARD_POST,
|
||||
title,
|
||||
|
||||
+63
-64
@@ -1,87 +1,86 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Notification;
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import de.cannamanage.domain.entity.NotificationPreference;
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
import de.cannamanage.service.push.FcmPushSender;
|
||||
import de.cannamanage.service.push.PushPayload;
|
||||
import de.cannamanage.service.push.WebPushSender;
|
||||
import de.cannamanage.service.repository.DeviceTokenRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import de.cannamanage.service.repository.NotificationPreferenceRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Multi-channel notification dispatch service.
|
||||
* When a notification is created, fans out to all enabled channels for the user.
|
||||
* Runs asynchronously to avoid blocking the main transaction.
|
||||
* Central dispatch service that fans out notifications to all enabled channels per user.
|
||||
* Channels: IN_APP (always on), EMAIL (opt-in), WEB_PUSH (opt-in), MOBILE_PUSH (opt-in).
|
||||
*
|
||||
* Called by NotificationService after creating the in-app notification record.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationDispatchService {
|
||||
|
||||
private final NotificationPreferenceService preferenceService;
|
||||
private final DeviceTokenRepository deviceTokenRepository;
|
||||
private final WebPushSender webPushSender;
|
||||
private final FcmPushSender fcmPushSender;
|
||||
private final NotificationPreferenceRepository preferenceRepository;
|
||||
private final EmailService emailService;
|
||||
|
||||
public NotificationDispatchService(NotificationPreferenceRepository preferenceRepository,
|
||||
EmailService emailService) {
|
||||
this.preferenceRepository = preferenceRepository;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a notification to all enabled channels for a user.
|
||||
* IN_APP is already handled by NotificationService (WebSocket push).
|
||||
* This handles the additional channels: EMAIL, WEB_PUSH, MOBILE_PUSH.
|
||||
* Dispatch a notification to all enabled channels for a single user.
|
||||
* IN_APP is already handled by the caller (NotificationService creates the DB record + WebSocket push).
|
||||
* This method handles EMAIL channel.
|
||||
*
|
||||
* @param userEmail the user's email (null if not available)
|
||||
* @param userName the user's display name
|
||||
*/
|
||||
@Async
|
||||
public void dispatch(Notification notification, UUID userId) {
|
||||
try {
|
||||
// Build push payload
|
||||
var payload = PushPayload.builder()
|
||||
.title(notification.getTitle())
|
||||
.body(notification.getMessage())
|
||||
.type(notification.getType().name())
|
||||
.icon("/icons/icon-192.png")
|
||||
.badge("/icons/icon-192.png")
|
||||
.url(notification.getLink() != null ? notification.getLink() : "/portal/notifications")
|
||||
.data(Map.of("notificationId", notification.getId().toString()))
|
||||
.build();
|
||||
|
||||
// WEB_PUSH
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.WEB_PUSH)) {
|
||||
var webTokens = deviceTokenRepository.findByUserIdAndPlatform(userId, DevicePlatform.WEB);
|
||||
for (var dt : webTokens) {
|
||||
webPushSender.send(dt.getToken(), payload);
|
||||
}
|
||||
if (!webTokens.isEmpty()) {
|
||||
log.debug("Dispatched Web Push to {} devices for user {}", webTokens.size(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
// MOBILE_PUSH
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.MOBILE_PUSH)) {
|
||||
var mobileTokens = deviceTokenRepository.findByUserIdAndPlatformIn(
|
||||
userId, List.of(DevicePlatform.IOS, DevicePlatform.ANDROID));
|
||||
for (var dt : mobileTokens) {
|
||||
fcmPushSender.send(dt.getToken(), payload);
|
||||
}
|
||||
if (!mobileTokens.isEmpty()) {
|
||||
log.debug("Dispatched FCM push to {} devices for user {}", mobileTokens.size(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
// EMAIL (queue — actual email sending deferred to Phase 4 IONOS integration)
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.EMAIL)) {
|
||||
log.debug("Email notification channel enabled for user {} — email sending deferred to Phase 4", userId);
|
||||
// emailService.sendNotificationEmail(user.getEmail(), notification);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error dispatching notification {} to user {}: {}", notification.getId(), userId, e.getMessage());
|
||||
public void dispatchToUser(UUID userId, String userEmail, String userName,
|
||||
String title, String message, String actionLink) {
|
||||
if (userEmail == null || userEmail.isBlank()) {
|
||||
log.debug("No email for user {}, skipping email dispatch", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user has EMAIL channel enabled
|
||||
boolean emailEnabled = preferenceRepository
|
||||
.findByUserIdAndChannel(userId, NotificationChannel.EMAIL)
|
||||
.map(NotificationPreference::isEnabled)
|
||||
.orElse(false); // Default: email is opt-in, so disabled if no preference exists
|
||||
|
||||
if (emailEnabled) {
|
||||
emailService.sendNotificationEmail(userEmail, userName, title, message, actionLink);
|
||||
log.debug("Email dispatched to {} for notification: {}", userEmail, title);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to dispatch email to user {}: {}", userId, e.getMessage());
|
||||
}
|
||||
|
||||
// Web Push and Mobile Push would be dispatched here in future
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch method called by NotificationService after saving a notification.
|
||||
* Fans out to email/push channels based on user preferences.
|
||||
*/
|
||||
@Async
|
||||
public void dispatch(de.cannamanage.domain.entity.Notification notification, UUID userId) {
|
||||
// For now, this is a no-op placeholder for push channels.
|
||||
// Email dispatch requires user email which is not on the Notification entity.
|
||||
// Full email integration will be triggered explicitly via dispatchToUser() when email is known.
|
||||
log.debug("Dispatch hook called for notification {} to user {}", notification.getId(), userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has email notifications enabled.
|
||||
*/
|
||||
public boolean isEmailEnabled(UUID userId) {
|
||||
return preferenceRepository
|
||||
.findByUserIdAndChannel(userId, NotificationChannel.EMAIL)
|
||||
.map(NotificationPreference::isEnabled)
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.enums.PlanTier;
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import de.cannamanage.service.repository.SubscriptionRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service to enforce plan tier restrictions on features.
|
||||
*
|
||||
* Tier rules:
|
||||
* - Forum: only Pro/Enterprise (Starter/Trial → 403)
|
||||
* - Info Board: Starter = 5 posts/month, Pro/Enterprise = unlimited
|
||||
* - Events: all tiers (no limit)
|
||||
* - Notifications: all tiers
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PlanTierService {
|
||||
|
||||
private static final int STARTER_INFO_BOARD_MONTHLY_LIMIT = 5;
|
||||
private static final int STARTER_INFO_BOARD_CATEGORIES_LIMIT = 3;
|
||||
private static final int PRO_FORUM_CATEGORIES_LIMIT = 5;
|
||||
|
||||
private final SubscriptionRepository subscriptionRepository;
|
||||
|
||||
public PlanTierService(SubscriptionRepository subscriptionRepository) {
|
||||
this.subscriptionRepository = subscriptionRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plan tier for a club.
|
||||
*/
|
||||
public PlanTier getClubTier(UUID clubId) {
|
||||
return subscriptionRepository.findByClubId(clubId)
|
||||
.map(sub -> sub.getPlanTier())
|
||||
.orElse(PlanTier.TRIAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club has access to the forum feature.
|
||||
* Forum is only available for Pro and Enterprise tiers.
|
||||
*
|
||||
* @throws QuotaExceededException if the club's tier doesn't support forum
|
||||
*/
|
||||
public void requireForumAccess(UUID clubId) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) {
|
||||
log.debug("Forum access denied for club {} (tier: {})", clubId, tier);
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Forum ist nur im Pro- oder Enterprise-Tarif verfügbar. " +
|
||||
"Bitte upgrade deinen Tarif, um das Forum zu nutzen."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club can create a new forum category.
|
||||
* Pro: max 5 categories, Enterprise: unlimited.
|
||||
*
|
||||
* @throws QuotaExceededException if the category limit is reached
|
||||
*/
|
||||
public void checkForumCategoryLimit(UUID clubId, int currentCategoryCount) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if (tier == PlanTier.PRO && currentCategoryCount >= PRO_FORUM_CATEGORIES_LIMIT) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Im Pro-Tarif sind maximal " + PRO_FORUM_CATEGORIES_LIMIT + " Forum-Kategorien möglich. " +
|
||||
"Upgrade auf Enterprise für unbegrenzte Kategorien."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club can create a new info board post this month.
|
||||
* Starter: max 5/month, Pro/Enterprise: unlimited.
|
||||
*
|
||||
* @throws QuotaExceededException if the monthly post limit is reached
|
||||
*/
|
||||
public void checkInfoBoardPostLimit(UUID clubId, int postsThisMonth) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if ((tier == PlanTier.STARTER || tier == PlanTier.TRIAL)
|
||||
&& postsThisMonth >= STARTER_INFO_BOARD_MONTHLY_LIMIT) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Im Starter-Tarif sind maximal " + STARTER_INFO_BOARD_MONTHLY_LIMIT +
|
||||
" Beiträge pro Monat im Schwarzen Brett möglich. " +
|
||||
"Upgrade auf Pro für unbegrenzte Beiträge."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club can create a new info board category.
|
||||
* Starter: max 3 categories, Pro/Enterprise: unlimited.
|
||||
*
|
||||
* @throws QuotaExceededException if the category limit is reached
|
||||
*/
|
||||
public void checkInfoBoardCategoryLimit(UUID clubId, int currentCategoryCount) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if ((tier == PlanTier.STARTER || tier == PlanTier.TRIAL)
|
||||
&& currentCategoryCount >= STARTER_INFO_BOARD_CATEGORIES_LIMIT) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Im Starter-Tarif sind maximal " + STARTER_INFO_BOARD_CATEGORIES_LIMIT +
|
||||
" Kategorien im Schwarzen Brett möglich. Upgrade auf Pro für mehr."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club has Enterprise tier (for custom FROM address feature).
|
||||
*
|
||||
* @throws QuotaExceededException if the club is not Enterprise
|
||||
*/
|
||||
public void requireEnterpriseTier(UUID clubId) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
if (tier != PlanTier.ENTERPRISE) {
|
||||
throw new QuotaExceededException(
|
||||
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||
"Diese Funktion ist nur im Enterprise-Tarif verfügbar."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club has at least Pro tier.
|
||||
*/
|
||||
public boolean isProOrHigher(UUID clubId) {
|
||||
PlanTier tier = getClubTier(clubId);
|
||||
return tier == PlanTier.PRO || tier == PlanTier.ENTERPRISE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a club has Enterprise tier.
|
||||
*/
|
||||
public boolean isEnterprise(UUID clubId) {
|
||||
return getClubTier(clubId) == PlanTier.ENTERPRISE;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for publishing real-time WebSocket events to connected clients.
|
||||
* Uses STOMP topics for broadcast (per-club) and user queues for targeted messages.
|
||||
*
|
||||
* Topic structure:
|
||||
* - /topic/club.{clubId}.infoboard — new info board posts
|
||||
* - /topic/club.{clubId}.forum — new forum topics/replies
|
||||
* - /topic/club.{clubId}.forum.{topicId} — replies to a specific topic
|
||||
* - /topic/club.{clubId}.events — event RSVP changes
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WebSocketEventPublisher {
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public WebSocketEventPublisher(SimpMessagingTemplate messagingTemplate) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a new info board post event to all connected club members.
|
||||
*/
|
||||
@Async
|
||||
public void publishInfoBoardPost(UUID clubId, UUID postId, String title, String category) {
|
||||
String destination = "/topic/club." + clubId + ".infoboard";
|
||||
Object payload = Map.of(
|
||||
"type", "NEW_POST",
|
||||
"postId", postId.toString(),
|
||||
"title", title,
|
||||
"category", category,
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSend(destination, payload);
|
||||
log.debug("WebSocket event published to {}: new post '{}'", destination, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a new forum topic event.
|
||||
*/
|
||||
@Async
|
||||
public void publishForumTopic(UUID clubId, UUID topicId, String title, String authorName) {
|
||||
String destination = "/topic/club." + clubId + ".forum";
|
||||
Object payload = Map.of(
|
||||
"type", "NEW_TOPIC",
|
||||
"topicId", topicId.toString(),
|
||||
"title", title,
|
||||
"authorName", authorName,
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSend(destination, payload);
|
||||
log.debug("WebSocket event published to {}: new topic '{}'", destination, title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a new forum reply event to topic subscribers.
|
||||
*/
|
||||
@Async
|
||||
public void publishForumReply(UUID clubId, UUID topicId, UUID replyId, String authorName) {
|
||||
// Notify the topic-specific channel
|
||||
String topicDestination = "/topic/club." + clubId + ".forum." + topicId;
|
||||
Object topicPayload = Map.of(
|
||||
"type", "NEW_REPLY",
|
||||
"topicId", topicId.toString(),
|
||||
"replyId", replyId.toString(),
|
||||
"authorName", authorName,
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSend(topicDestination, topicPayload);
|
||||
|
||||
// Also notify the general forum channel (for reply counts)
|
||||
String forumDestination = "/topic/club." + clubId + ".forum";
|
||||
Object forumPayload = Map.of(
|
||||
"type", "TOPIC_UPDATED",
|
||||
"topicId", topicId.toString(),
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSend(forumDestination, forumPayload);
|
||||
log.debug("WebSocket reply event published for topic {}", topicId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event RSVP change to the event creator.
|
||||
*/
|
||||
@Async
|
||||
public void publishEventRsvpChange(UUID clubId, UUID eventId, String eventTitle,
|
||||
String memberName, String rsvpStatus) {
|
||||
String destination = "/topic/club." + clubId + ".events";
|
||||
Object payload = Map.of(
|
||||
"type", "RSVP_CHANGED",
|
||||
"eventId", eventId.toString(),
|
||||
"eventTitle", eventTitle,
|
||||
"memberName", memberName,
|
||||
"rsvpStatus", rsvpStatus,
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSend(destination, payload);
|
||||
log.debug("WebSocket RSVP event published for event {}: {} → {}", eventId, memberName, rsvpStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a notification to a specific user's queue.
|
||||
*/
|
||||
public void publishToUser(UUID userId, String title, String message, String link) {
|
||||
String destination = "/queue/notifications";
|
||||
Map<String, Object> payload = Map.of(
|
||||
"type", "NOTIFICATION",
|
||||
"title", title,
|
||||
"message", message,
|
||||
"link", link != null ? link : "",
|
||||
"timestamp", Instant.now().toString()
|
||||
);
|
||||
messagingTemplate.convertAndSendToUser(userId.toString(), destination, payload);
|
||||
log.debug("WebSocket notification sent to user {}: '{}'", userId, title);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -5,5 +5,6 @@ public enum QuotaViolationCode {
|
||||
QUOTA_EXCEEDED_DAILY,
|
||||
QUOTA_EXCEEDED_MONTHLY,
|
||||
HIGH_THC_RESTRICTED_UNDER_21,
|
||||
BATCH_UNAVAILABLE
|
||||
BATCH_UNAVAILABLE,
|
||||
TIER_UPGRADE_REQUIRED
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface CustomMailDomainRepository extends JpaRepository<CustomMailDomain, UUID> {
|
||||
|
||||
Optional<CustomMailDomain> findByTenantId(UUID tenantId);
|
||||
|
||||
boolean existsByTenantId(UUID tenantId);
|
||||
}
|
||||
+13
@@ -31,6 +31,19 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||
*/
|
||||
List<Member> findByTenantIdAndUnder21True(UUID tenantId);
|
||||
|
||||
java.util.Optional<Member> findByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Alias for findByTenantId — in CannaManage, clubId == tenantId.
|
||||
*/
|
||||
default List<Member> findByClubId(UUID clubId) {
|
||||
return findByTenantId(clubId);
|
||||
}
|
||||
|
||||
default List<Member> findAllByClubId(UUID clubId) {
|
||||
return findByTenantId(clubId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active member user IDs (for broadcast notifications).
|
||||
* Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.
|
||||
|
||||
@@ -23,7 +23,7 @@ class EmailServiceTest {
|
||||
|
||||
@Test
|
||||
void sendInviteEmail_sendsCorrectContent() {
|
||||
emailService = new EmailService(mailSender, "https://app.cannamanage.de", "noreply@cannamanage.de");
|
||||
emailService = new EmailService(mailSender, "https://app.cannamanage.de", "noreply@cannamanage.de", "support@cannamanage.de", 50);
|
||||
|
||||
emailService.sendInviteEmail("staff@example.com", "Max Mustermann", "Green Club", "abc123token");
|
||||
|
||||
@@ -41,7 +41,7 @@ class EmailServiceTest {
|
||||
|
||||
@Test
|
||||
void sendInviteEmail_mailFailure_throwsRuntimeException() {
|
||||
emailService = new EmailService(mailSender, "http://localhost:8080", "noreply@cannamanage.de");
|
||||
emailService = new EmailService(mailSender, "http://localhost:8080", "noreply@cannamanage.de", "support@cannamanage.de", 50);
|
||||
doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
|
||||
Reference in New Issue
Block a user