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:
Patrick Plate
2026-06-13 20:51:10 +02:00
parent a539ed9eb2
commit aabde17532
26 changed files with 1174 additions and 82 deletions
@@ -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);
@@ -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,
@@ -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;
}
}
@@ -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);
}
}
@@ -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
}
@@ -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);
}
@@ -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(() ->