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
@@ -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)) .map(staff -> staff.hasPermission(required))
.orElse(false); .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. # drag /actuator/health to DOWN (503), which would mark the container unhealthy.
management.health.mail.enabled=false management.health.mail.enabled=false
# Disable mail in Docker (no SMTP container) # IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production
spring.mail.host=localhost spring.mail.host=${SMTP_HOST:smtp.ionos.de}
spring.mail.port=1025 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) # Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
push.vapid.public-key=${VAPID_PUBLIC_KEY:} push.vapid.public-key=${VAPID_PUBLIC_KEY:}
@@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false
# App base URL # App base URL
app.base-url=https://cannamanage.plate-software.de 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);
@@ -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;
}
}
@@ -15,6 +15,9 @@ import java.util.UUID;
) )
public class Member extends AbstractTenantEntity { public class Member extends AbstractTenantEntity {
@Column(name = "user_id")
private UUID userId;
@Column(name = "club_id", nullable = false) @Column(name = "club_id", nullable = false)
private UUID clubId; private UUID clubId;
@@ -46,6 +49,9 @@ public class Member extends AbstractTenantEntity {
@Column(name = "prevention_officer", nullable = false) @Column(name = "prevention_officer", nullable = false)
private boolean preventionOfficer = false; private boolean preventionOfficer = false;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getClubId() { return clubId; } public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; } public void setClubId(UUID clubId) { this.clubId = clubId; }
@@ -17,6 +17,11 @@ public final class TenantContext {
return CURRENT_TENANT.get(); return CURRENT_TENANT.get();
} }
/** Alias for getCurrentTenant() — used by controllers expecting this name. */
public static UUID getCurrentTenantId() {
return CURRENT_TENANT.get();
}
public static void setCurrentTenant(UUID tenantId) { public static void setCurrentTenant(UUID tenantId) {
CURRENT_TENANT.set(tenantId); CURRENT_TENANT.set(tenantId);
} }
@@ -48,8 +48,10 @@ public enum AuditEventType {
// Sprint 7 — Info Board events // Sprint 7 — Info Board events
INFO_BOARD_POST_CREATED, INFO_BOARD_POST_CREATED,
INFO_BOARD_POST_EDITED, INFO_BOARD_POST_EDITED,
INFO_BOARD_POST_UPDATED,
INFO_BOARD_POST_PINNED, INFO_BOARD_POST_PINNED,
INFO_BOARD_POST_ARCHIVED, INFO_BOARD_POST_ARCHIVED,
INFO_BOARD_POST_DELETED,
// Sprint 7 — Event Calendar events // Sprint 7 — Event Calendar events
EVENT_CREATED, EVENT_CREATED,
+3
View File
@@ -243,6 +243,9 @@
"networkError": "Verbindungsfehler. Bitte versuche es erneut.", "networkError": "Verbindungsfehler. Bitte versuche es erneut.",
"welcome": "Willkommen zurück, {name}!", "welcome": "Willkommen zurück, {name}!",
"dashboard": "Übersicht", "dashboard": "Übersicht",
"infoBoard": "Ankündigungen",
"events": "Termine",
"forum": "Forum",
"quota": "Mein Kontingent", "quota": "Mein Kontingent",
"history": "Ausgabe-Verlauf", "history": "Ausgabe-Verlauf",
"profile": "Profil", "profile": "Profil",
+3
View File
@@ -243,6 +243,9 @@
"networkError": "Connection error. Please try again.", "networkError": "Connection error. Please try again.",
"welcome": "Welcome back, {name}!", "welcome": "Welcome back, {name}!",
"dashboard": "Overview", "dashboard": "Overview",
"infoBoard": "Announcements",
"events": "Events",
"forum": "Forum",
"quota": "My Quota", "quota": "My Quota",
"history": "Distribution History", "history": "Distribution History",
"profile": "Profile", "profile": "Profile",
@@ -3,7 +3,7 @@
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Cannabis, History, LayoutDashboard, LogOut, User } from "lucide-react" import { Calendar, Cannabis, History, LayoutDashboard, LogOut, Megaphone, MessageSquare, User } from "lucide-react"
import { mockPortalUser } from "@/data/mock/portal" import { mockPortalUser } from "@/data/mock/portal"
@@ -13,6 +13,9 @@ import { ModeDropdown } from "@/components/layout/mode-dropdown"
const navItems = [ const navItems = [
{ href: "/portal/dashboard", icon: LayoutDashboard, labelKey: "dashboard" }, { href: "/portal/dashboard", icon: LayoutDashboard, labelKey: "dashboard" },
{ href: "/portal/info-board", icon: Megaphone, labelKey: "infoBoard" },
{ href: "/portal/events", icon: Calendar, labelKey: "events" },
{ href: "/portal/forum", icon: MessageSquare, labelKey: "forum" },
{ href: "/portal/history", icon: History, labelKey: "history" }, { href: "/portal/history", icon: History, labelKey: "history" },
{ href: "/portal/profile", icon: User, labelKey: "profile" }, { href: "/portal/profile", icon: User, labelKey: "profile" },
] as const ] as const
@@ -0,0 +1,84 @@
"use client"
import { useEffect, useCallback, useRef } from "react"
/**
* Hook to subscribe to forum WebSocket events for a specific club.
* Listens for new topics and replies in real-time.
*/
export function useForumSubscription(
clubId: string | undefined,
topicId?: string,
onNewTopic?: (data: ForumTopicEvent) => void,
onNewReply?: (data: ForumReplyEvent) => void
) {
const stompClientRef = useRef<any>(null)
useEffect(() => {
if (!clubId) return
// Dynamic import of SockJS client
const connectWebSocket = async () => {
try {
const SockJS = (await import("sockjs-client")).default
const { Client } = await import("@stomp/stompjs")
const client = new Client({
webSocketFactory: () => new SockJS("/ws"),
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
})
client.onConnect = () => {
// Subscribe to general forum channel
client.subscribe(`/topic/club.${clubId}.forum`, (message) => {
const data = JSON.parse(message.body)
if (data.type === "NEW_TOPIC" && onNewTopic) {
onNewTopic(data as ForumTopicEvent)
}
})
// Subscribe to specific topic if provided
if (topicId) {
client.subscribe(`/topic/club.${clubId}.forum.${topicId}`, (message) => {
const data = JSON.parse(message.body)
if (data.type === "NEW_REPLY" && onNewReply) {
onNewReply(data as ForumReplyEvent)
}
})
}
}
client.activate()
stompClientRef.current = client
} catch (error) {
console.warn("WebSocket connection failed:", error)
}
}
connectWebSocket()
return () => {
if (stompClientRef.current) {
stompClientRef.current.deactivate()
}
}
}, [clubId, topicId, onNewTopic, onNewReply])
}
export interface ForumTopicEvent {
type: "NEW_TOPIC"
topicId: string
title: string
authorName: string
timestamp: string
}
export interface ForumReplyEvent {
type: "NEW_REPLY"
topicId: string
replyId: string
authorName: string
timestamp: string
}
@@ -0,0 +1,62 @@
"use client"
import { useEffect, useRef } from "react"
/**
* Hook to subscribe to info board WebSocket events for a specific club.
* Pushes new post notifications to connected members in real-time.
*/
export function useInfoBoardSubscription(
clubId: string | undefined,
onNewPost?: (data: InfoBoardPostEvent) => void
) {
const stompClientRef = useRef<any>(null)
useEffect(() => {
if (!clubId) return
const connectWebSocket = async () => {
try {
const SockJS = (await import("sockjs-client")).default
const { Client } = await import("@stomp/stompjs")
const client = new Client({
webSocketFactory: () => new SockJS("/ws"),
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
})
client.onConnect = () => {
client.subscribe(`/topic/club.${clubId}.infoboard`, (message) => {
const data = JSON.parse(message.body)
if (data.type === "NEW_POST" && onNewPost) {
onNewPost(data as InfoBoardPostEvent)
}
})
}
client.activate()
stompClientRef.current = client
} catch (error) {
console.warn("WebSocket connection failed:", error)
}
}
connectWebSocket()
return () => {
if (stompClientRef.current) {
stompClientRef.current.deactivate()
}
}
}, [clubId, onNewPost])
}
export interface InfoBoardPostEvent {
type: "NEW_POST"
postId: string
title: string
category: string
timestamp: string
}
@@ -90,6 +90,22 @@ public class AuditService {
/** /**
* Export audit events as PDF bytes for a given date range. * 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) @Transactional(readOnly = true)
public byte[] exportPdf(UUID tenantId, Instant from, Instant to) { public byte[] exportPdf(UUID tenantId, Instant from, Instant to) {
List<AuditEvent> events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, 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.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; 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. * Email service for sending transactional and notification emails via IONOS SMTP.
* Uses plain text templates — no Thymeleaf dependency needed. * 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 @Slf4j
@Service @Service
@@ -17,18 +31,27 @@ public class EmailService {
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
private final String baseUrl; private final String baseUrl;
private final String fromAddress; 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, public EmailService(JavaMailSender mailSender,
@Value("${app.base-url:http://localhost:8080}") String baseUrl, @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.mailSender = mailSender;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.fromAddress = fromAddress; 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. * 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, public void sendInviteEmail(String recipientEmail, String displayName,
String clubName, String token) { String clubName, String token) {
@@ -51,18 +74,200 @@ public class EmailService {
Dein CannaManage-Team Dein CannaManage-Team
""", displayName, clubName, setPasswordUrl); """, 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(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(fromAddress); message.setFrom(fromAddress);
message.setTo(recipientEmail); message.setReplyTo(replyToAddress);
message.setSubject("Einladung: " + clubName + " — Account aktivieren"); message.setTo(to);
message.setSubject(subject);
message.setText(body); message.setText(body);
try { try {
mailSender.send(message); mailSender.send(message);
log.info("Invite email sent to {} for club '{}'", recipientEmail, clubName); log.debug("Email sent to {} — subject: {}", to, subject);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to send invite email to {}: {}", recipientEmail, e.getMessage()); log.error("Failed to send email to {}: {}", to, e.getMessage());
throw new RuntimeException("Failed to send invite email", e); 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 // Dispatch notification to all club members
try { try {
var members = memberRepository.findByClubId(clubId); var members = memberRepository.findAllByClubId(clubId);
members.forEach(member -> { members.forEach(member -> {
if (member.getUserId() != null) { if (member.getUserId() != null) {
notificationService.createNotification( notificationService.sendNotification(
member.getUserId(), member.getUserId(),
NotificationType.INFO_BOARD_POST, NotificationType.INFO_BOARD_POST,
title, title,
@@ -1,87 +1,86 @@
package de.cannamanage.service; package de.cannamanage.service;
import de.cannamanage.domain.entity.Notification; import de.cannamanage.domain.entity.NotificationPreference;
import de.cannamanage.domain.enums.DevicePlatform;
import de.cannamanage.domain.enums.NotificationChannel; import de.cannamanage.domain.enums.NotificationChannel;
import de.cannamanage.service.push.FcmPushSender; import de.cannamanage.service.repository.NotificationPreferenceRepository;
import de.cannamanage.service.push.PushPayload;
import de.cannamanage.service.push.WebPushSender;
import de.cannamanage.service.repository.DeviceTokenRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
/** /**
* Multi-channel notification dispatch service. * Central dispatch service that fans out notifications to all enabled channels per user.
* When a notification is created, fans out to all enabled channels for the user. * Channels: IN_APP (always on), EMAIL (opt-in), WEB_PUSH (opt-in), MOBILE_PUSH (opt-in).
* Runs asynchronously to avoid blocking the main transaction. *
* Called by NotificationService after creating the in-app notification record.
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor
public class NotificationDispatchService { public class NotificationDispatchService {
private final NotificationPreferenceService preferenceService; private final NotificationPreferenceRepository preferenceRepository;
private final DeviceTokenRepository deviceTokenRepository;
private final WebPushSender webPushSender;
private final FcmPushSender fcmPushSender;
private final EmailService emailService; 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. * Dispatch a notification to all enabled channels for a single user.
* IN_APP is already handled by NotificationService (WebSocket push). * IN_APP is already handled by the caller (NotificationService creates the DB record + WebSocket push).
* This handles the additional channels: EMAIL, WEB_PUSH, MOBILE_PUSH. * This method handles EMAIL channel.
*
* @param userEmail the user's email (null if not available)
* @param userName the user's display name
*/ */
@Async @Async
public void dispatch(Notification notification, UUID userId) { public void dispatchToUser(UUID userId, String userEmail, String userName,
try { String title, String message, String actionLink) {
// Build push payload if (userEmail == null || userEmail.isBlank()) {
var payload = PushPayload.builder() log.debug("No email for user {}, skipping email dispatch", userId);
.title(notification.getTitle()) return;
.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());
} }
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_DAILY,
QUOTA_EXCEEDED_MONTHLY, QUOTA_EXCEEDED_MONTHLY,
HIGH_THC_RESTRICTED_UNDER_21, 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); 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). * Get all active member user IDs (for broadcast notifications).
* Uses the Hibernate tenant filter, so no explicit tenantId parameter needed. * Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.
@@ -23,7 +23,7 @@ class EmailServiceTest {
@Test @Test
void sendInviteEmail_sendsCorrectContent() { 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"); emailService.sendInviteEmail("staff@example.com", "Max Mustermann", "Green Club", "abc123token");
@@ -41,7 +41,7 @@ class EmailServiceTest {
@Test @Test
void sendInviteEmail_mailFailure_throwsRuntimeException() { 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)); doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
assertThatThrownBy(() -> assertThatThrownBy(() ->