diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/MailSettingsController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MailSettingsController.java new file mode 100644 index 0000000..6e5ebc9 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/MailSettingsController.java @@ -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 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 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 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 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 + ) {} +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java index 455121b..9e73090 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/StaffPermissionChecker.java @@ -54,4 +54,35 @@ public class StaffPermissionChecker { .map(staff -> staff.hasPermission(required)) .orElse(false); } + + /** + * Imperative permission check — throws AccessDeniedException if permission is missing. + * Used by controllers that need to guard specific endpoints programmatically. + */ + public void requirePermission(org.springframework.security.core.userdetails.UserDetails principal, StaffPermission required) { + if (principal == null) { + throw new org.springframework.security.access.AccessDeniedException("Not authenticated"); + } + // Convert UserDetails to Authentication-like check + UUID userId = UUID.fromString(principal.getUsername()); + boolean isAdmin = principal.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(a -> a.equals("ROLE_ADMIN")); + if (isAdmin) return; + + boolean isStaff = principal.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(a -> a.equals("ROLE_STAFF")); + if (!isStaff) { + throw new org.springframework.security.access.AccessDeniedException("Insufficient permissions"); + } + + boolean hasPermission = staffAccountRepository.findByUserId(userId) + .filter(StaffAccount::isActive) + .map(staff -> staff.hasPermission(required)) + .orElse(false); + if (!hasPermission) { + throw new org.springframework.security.access.AccessDeniedException("Missing permission: " + required); + } + } } diff --git a/cannamanage-api/src/main/resources/application-docker.properties b/cannamanage-api/src/main/resources/application-docker.properties index 364ab7f..5997de1 100644 --- a/cannamanage-api/src/main/resources/application-docker.properties +++ b/cannamanage-api/src/main/resources/application-docker.properties @@ -17,9 +17,18 @@ management.endpoint.health.show-details=never # drag /actuator/health to DOWN (503), which would mark the container unhealthy. management.health.mail.enabled=false -# Disable mail in Docker (no SMTP container) -spring.mail.host=localhost -spring.mail.port=1025 +# IONOS SMTP relay (plate-software.de) — Docker uses same SMTP as production +spring.mail.host=${SMTP_HOST:smtp.ionos.de} +spring.mail.port=${SMTP_PORT:587} +spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de} +spring.mail.password=${IONOS_SMTP_PASSWORD:} +spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:true} +spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:true} +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de} +cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de} +cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de} +cannamanage.mail.rate-limit=${MAIL_RATE_LIMIT:50} # Web Push VAPID keys (generate via: npx web-push generate-vapid-keys) push.vapid.public-key=${VAPID_PUBLIC_KEY:} diff --git a/cannamanage-api/src/main/resources/application-production.properties b/cannamanage-api/src/main/resources/application-production.properties index 8499ee5..7d776e1 100644 --- a/cannamanage-api/src/main/resources/application-production.properties +++ b/cannamanage-api/src/main/resources/application-production.properties @@ -53,3 +53,25 @@ springdoc.swagger-ui.enabled=false # App base URL app.base-url=https://cannamanage.plate-software.de + +# IONOS SMTP relay (plate-software.de) +spring.mail.host=smtp.ionos.de +spring.mail.port=587 +spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de} +spring.mail.password=${IONOS_SMTP_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.starttls.required=true +spring.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de} +cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de} +cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de} +cannamanage.mail.rate-limit=50 + +# Web Push VAPID keys +push.vapid.public-key=${VAPID_PUBLIC_KEY:} +push.vapid.private-key=${VAPID_PRIVATE_KEY:} +push.vapid.subject=mailto:admin@cannamanage.plate-software.de + +# Firebase Cloud Messaging +push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:} +push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod} diff --git a/cannamanage-api/src/main/resources/db/migration/V16__email_dispatch_index.sql b/cannamanage-api/src/main/resources/db/migration/V16__email_dispatch_index.sql new file mode 100644 index 0000000..1205145 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V16__email_dispatch_index.sql @@ -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; diff --git a/cannamanage-api/src/main/resources/db/migration/V17__custom_mail_domains.sql b/cannamanage-api/src/main/resources/db/migration/V17__custom_mail_domains.sql new file mode 100644 index 0000000..d44062c --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V17__custom_mail_domains.sql @@ -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); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/CustomMailDomain.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/CustomMailDomain.java new file mode 100644 index 0000000..294ba43 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/CustomMailDomain.java @@ -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; + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java index 1cabbeb..b885665 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Member.java @@ -15,6 +15,9 @@ import java.util.UUID; ) public class Member extends AbstractTenantEntity { + @Column(name = "user_id") + private UUID userId; + @Column(name = "club_id", nullable = false) private UUID clubId; @@ -46,6 +49,9 @@ public class Member extends AbstractTenantEntity { @Column(name = "prevention_officer", nullable = false) private boolean preventionOfficer = false; + public UUID getUserId() { return userId; } + public void setUserId(UUID userId) { this.userId = userId; } + public UUID getClubId() { return clubId; } public void setClubId(UUID clubId) { this.clubId = clubId; } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java index b5edb0d..c51b464 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/TenantContext.java @@ -17,6 +17,11 @@ public final class TenantContext { 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) { CURRENT_TENANT.set(tenantId); } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java index 8d2b84c..6cfca9d 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java @@ -48,8 +48,10 @@ public enum AuditEventType { // Sprint 7 — Info Board events INFO_BOARD_POST_CREATED, INFO_BOARD_POST_EDITED, + INFO_BOARD_POST_UPDATED, INFO_BOARD_POST_PINNED, INFO_BOARD_POST_ARCHIVED, + INFO_BOARD_POST_DELETED, // Sprint 7 — Event Calendar events EVENT_CREATED, diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 5be2e4c..f74d6e5 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -243,6 +243,9 @@ "networkError": "Verbindungsfehler. Bitte versuche es erneut.", "welcome": "Willkommen zurück, {name}!", "dashboard": "Übersicht", + "infoBoard": "Ankündigungen", + "events": "Termine", + "forum": "Forum", "quota": "Mein Kontingent", "history": "Ausgabe-Verlauf", "profile": "Profil", diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index 9c66429..c099d51 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -243,6 +243,9 @@ "networkError": "Connection error. Please try again.", "welcome": "Welcome back, {name}!", "dashboard": "Overview", + "infoBoard": "Announcements", + "events": "Events", + "forum": "Forum", "quota": "My Quota", "history": "Distribution History", "profile": "Profile", diff --git a/cannamanage-frontend/src/components/portal/portal-navbar.tsx b/cannamanage-frontend/src/components/portal/portal-navbar.tsx index 8e363e5..736c75c 100644 --- a/cannamanage-frontend/src/components/portal/portal-navbar.tsx +++ b/cannamanage-frontend/src/components/portal/portal-navbar.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" 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" @@ -13,6 +13,9 @@ import { ModeDropdown } from "@/components/layout/mode-dropdown" const navItems = [ { 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/profile", icon: User, labelKey: "profile" }, ] as const diff --git a/cannamanage-frontend/src/hooks/use-forum-subscription.ts b/cannamanage-frontend/src/hooks/use-forum-subscription.ts new file mode 100644 index 0000000..7707358 --- /dev/null +++ b/cannamanage-frontend/src/hooks/use-forum-subscription.ts @@ -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(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 +} diff --git a/cannamanage-frontend/src/hooks/use-info-board-subscription.ts b/cannamanage-frontend/src/hooks/use-info-board-subscription.ts new file mode 100644 index 0000000..40041e5 --- /dev/null +++ b/cannamanage-frontend/src/hooks/use-info-board-subscription.ts @@ -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(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 +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java index e46aafd..b015258 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java @@ -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 events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, to); diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/CustomMailDomainService.java b/cannamanage-service/src/main/java/de/cannamanage/service/CustomMailDomainService.java new file mode 100644 index 0000000..b7b8c5d --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/CustomMailDomainService.java @@ -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 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 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 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(); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java b/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java index 1961807..ca3cd4a 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java @@ -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 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) {} } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/InfoBoardService.java b/cannamanage-service/src/main/java/de/cannamanage/service/InfoBoardService.java index de4567d..f860a4d 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/InfoBoardService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/InfoBoardService.java @@ -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, diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/NotificationDispatchService.java b/cannamanage-service/src/main/java/de/cannamanage/service/NotificationDispatchService.java index 854bc41..aca4949 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/NotificationDispatchService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/NotificationDispatchService.java @@ -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); } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java b/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java new file mode 100644 index 0000000..6ec8ab3 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PlanTierService.java @@ -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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/WebSocketEventPublisher.java b/cannamanage-service/src/main/java/de/cannamanage/service/WebSocketEventPublisher.java new file mode 100644 index 0000000..28a2bec --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/WebSocketEventPublisher.java @@ -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 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); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java index 1516dfa..b59c6b5 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/exception/QuotaViolationCode.java @@ -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 } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/CustomMailDomainRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/CustomMailDomainRepository.java new file mode 100644 index 0000000..74531ae --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/CustomMailDomainRepository.java @@ -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 { + + Optional findByTenantId(UUID tenantId); + + boolean existsByTenantId(UUID tenantId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java index f1f1e15..27f6e75 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberRepository.java @@ -31,6 +31,19 @@ public interface MemberRepository extends JpaRepository { */ List findByTenantIdAndUnder21True(UUID tenantId); + java.util.Optional findByUserId(UUID userId); + + /** + * Alias for findByTenantId — in CannaManage, clubId == tenantId. + */ + default List findByClubId(UUID clubId) { + return findByTenantId(clubId); + } + + default List 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. diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java index 9f68beb..9fad947 100644 --- a/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java +++ b/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java @@ -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(() ->