feat(sprint7): Phase 4 — Integration (SMTP, tier enforcement, WebSocket)
Phase 4 implementation: - 4.1 IONOS SMTP email configuration (production + docker profiles) - 4.2 Portal navigation update (info board, events, forum links) - 4.3 Tier enforcement: PlanTierService (forum=Pro+, info board limits) - 4.4 WebSocket real-time updates (WebSocketEventPublisher) - 4.5 EmailService: notification, event reminder, info board templates + rate limiting - 4.6 Enterprise custom FROM: CustomMailDomain entity, DNS verification, controller New files: - PlanTierService: tier checks for forum/info board/enterprise features - NotificationDispatchService: EMAIL channel dispatch via preferences - WebSocketEventPublisher: STOMP topic push for forum/info board/events - CustomMailDomainService: DNS TXT record verification for custom FROM - MailSettingsController: Enterprise custom domain API endpoints - CustomMailDomain entity + repository - V16 migration: email dispatch index - V17 migration: custom_mail_domains table - Frontend: use-forum-subscription + use-info-board-subscription hooks - Portal navbar: added info board, events, forum navigation items - i18n: added portal nav translations (de + en) Also fixed pre-existing Phase 2.5/3 compilation issues: - Member entity: added userId field - AuditService: added convenience overloads (logEvent, 4-param log) - AuditEventType: added INFO_BOARD_POST_UPDATED, INFO_BOARD_POST_DELETED - QuotaViolationCode: added TIER_UPGRADE_REQUIRED - StaffPermissionChecker: added requirePermission(UserDetails, ...) - TenantContext: added getCurrentTenantId() alias - MemberRepository: added findByUserId, findByClubId, findAllByClubId - EmailServiceTest: updated for new constructor signature
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package de.cannamanage.api.controller;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||||
|
import de.cannamanage.domain.entity.TenantContext;
|
||||||
|
import de.cannamanage.service.CustomMailDomainService;
|
||||||
|
import de.cannamanage.service.PlanTierService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for Enterprise custom email domain management.
|
||||||
|
* All endpoints require ADMIN role + Enterprise tier.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/settings/mail")
|
||||||
|
public class MailSettingsController {
|
||||||
|
|
||||||
|
private final CustomMailDomainService customMailDomainService;
|
||||||
|
private final PlanTierService planTierService;
|
||||||
|
|
||||||
|
public MailSettingsController(CustomMailDomainService customMailDomainService,
|
||||||
|
PlanTierService planTierService) {
|
||||||
|
this.customMailDomainService = customMailDomainService;
|
||||||
|
this.planTierService = planTierService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom FROM address for the club's outbound emails.
|
||||||
|
* Enterprise tier only.
|
||||||
|
*/
|
||||||
|
@PostMapping("/custom-domain")
|
||||||
|
public ResponseEntity<MailDomainStatusResponse> setCustomDomain(
|
||||||
|
@Valid @RequestBody CustomMailDomainRequest request) {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||||
|
CustomMailDomain domain = customMailDomainService.setCustomDomain(tenantId, request.fromAddress());
|
||||||
|
return ResponseEntity.ok(toResponse(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current custom domain status.
|
||||||
|
*/
|
||||||
|
@GetMapping("/custom-domain")
|
||||||
|
public ResponseEntity<MailDomainStatusResponse> getCustomDomain() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||||
|
planTierService.requireEnterpriseTier(tenantId);
|
||||||
|
|
||||||
|
return customMailDomainService.getCustomDomain(tenantId)
|
||||||
|
.map(domain -> ResponseEntity.ok(toResponse(domain)))
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger DNS verification for the custom domain.
|
||||||
|
*/
|
||||||
|
@PostMapping("/custom-domain/verify")
|
||||||
|
public ResponseEntity<MailDomainStatusResponse> verifyCustomDomain() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||||
|
CustomMailDomain domain = customMailDomainService.verifyDomain(tenantId);
|
||||||
|
return ResponseEntity.ok(toResponse(domain));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove custom domain configuration (revert to platform default).
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/custom-domain")
|
||||||
|
public ResponseEntity<Void> removeCustomDomain() {
|
||||||
|
UUID tenantId = TenantContext.getCurrentTenantId();
|
||||||
|
planTierService.requireEnterpriseTier(tenantId);
|
||||||
|
customMailDomainService.removeCustomDomain(tenantId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MailDomainStatusResponse toResponse(CustomMailDomain domain) {
|
||||||
|
return new MailDomainStatusResponse(
|
||||||
|
domain.getFromAddress(),
|
||||||
|
domain.getDomain(),
|
||||||
|
domain.isVerified(),
|
||||||
|
domain.getVerificationToken(),
|
||||||
|
domain.getVerifiedAt() != null ? domain.getVerifiedAt().toString() : null,
|
||||||
|
"cannamanage-verify=" + domain.getVerificationToken()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DTOs ---
|
||||||
|
|
||||||
|
public record CustomMailDomainRequest(
|
||||||
|
@NotBlank @Email String fromAddress
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record MailDomainStatusResponse(
|
||||||
|
String fromAddress,
|
||||||
|
String domain,
|
||||||
|
boolean verified,
|
||||||
|
String verificationToken,
|
||||||
|
String verifiedAt,
|
||||||
|
String requiredDnsTxtRecord
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -54,4 +54,35 @@ public class StaffPermissionChecker {
|
|||||||
.map(staff -> staff.hasPermission(required))
|
.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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||||
|
import de.cannamanage.service.repository.CustomMailDomainRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import javax.naming.NamingEnumeration;
|
||||||
|
import javax.naming.directory.Attribute;
|
||||||
|
import javax.naming.directory.Attributes;
|
||||||
|
import javax.naming.directory.DirContext;
|
||||||
|
import javax.naming.directory.InitialDirContext;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Hashtable;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing custom mail domains for Enterprise tier clubs.
|
||||||
|
*
|
||||||
|
* Verification flow:
|
||||||
|
* 1. Admin enters desired FROM address (e.g., info@gruener-daumen.de)
|
||||||
|
* 2. System extracts domain, generates unique verification token
|
||||||
|
* 3. Admin adds DNS TXT record: cannamanage-verify={token}
|
||||||
|
* 4. Admin clicks "Verify" → system does DNS TXT lookup
|
||||||
|
* 5. If record matches → mark as verified
|
||||||
|
* 6. Outbound emails now use the custom FROM address
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class CustomMailDomainService {
|
||||||
|
|
||||||
|
private static final String DNS_TXT_PREFIX = "cannamanage-verify=";
|
||||||
|
|
||||||
|
private final CustomMailDomainRepository repository;
|
||||||
|
private final PlanTierService planTierService;
|
||||||
|
|
||||||
|
public CustomMailDomainService(CustomMailDomainRepository repository,
|
||||||
|
PlanTierService planTierService) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.planTierService = planTierService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a custom FROM address for an Enterprise club.
|
||||||
|
* Generates a verification token that must be added as DNS TXT record.
|
||||||
|
*/
|
||||||
|
public CustomMailDomain setCustomDomain(UUID tenantId, String fromAddress) {
|
||||||
|
planTierService.requireEnterpriseTier(tenantId);
|
||||||
|
|
||||||
|
String domain = extractDomain(fromAddress);
|
||||||
|
String token = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
|
||||||
|
|
||||||
|
// Remove existing config if any
|
||||||
|
repository.findByTenantId(tenantId).ifPresent(repository::delete);
|
||||||
|
|
||||||
|
CustomMailDomain config = new CustomMailDomain(tenantId, fromAddress, domain, token);
|
||||||
|
CustomMailDomain saved = repository.save(config);
|
||||||
|
|
||||||
|
log.info("Custom mail domain set for club {}: {} (domain: {})", tenantId, fromAddress, domain);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current custom domain configuration for a club.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<CustomMailDomain> getCustomDomain(UUID tenantId) {
|
||||||
|
return repository.findByTenantId(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the custom domain by checking DNS TXT records.
|
||||||
|
* Looks for: cannamanage-verify={token}
|
||||||
|
*/
|
||||||
|
public CustomMailDomain verifyDomain(UUID tenantId) {
|
||||||
|
planTierService.requireEnterpriseTier(tenantId);
|
||||||
|
|
||||||
|
CustomMailDomain config = repository.findByTenantId(tenantId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No custom domain configured for this club"));
|
||||||
|
|
||||||
|
if (config.isVerified()) {
|
||||||
|
return config; // Already verified
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean verified = checkDnsTxtRecord(config.getDomain(), config.getVerificationToken());
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
config.setVerified(true);
|
||||||
|
config.setVerifiedAt(Instant.now());
|
||||||
|
repository.save(config);
|
||||||
|
log.info("Custom mail domain verified for club {}: {}", tenantId, config.getDomain());
|
||||||
|
} else {
|
||||||
|
log.info("DNS verification failed for club {} domain {}", tenantId, config.getDomain());
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"DNS-Verifikation fehlgeschlagen. Bitte stelle sicher, dass der TXT-Eintrag " +
|
||||||
|
"'" + DNS_TXT_PREFIX + config.getVerificationToken() + "' " +
|
||||||
|
"für die Domain '" + config.getDomain() + "' korrekt gesetzt ist. " +
|
||||||
|
"DNS-Änderungen können bis zu 48 Stunden dauern."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove custom domain configuration.
|
||||||
|
*/
|
||||||
|
public void removeCustomDomain(UUID tenantId) {
|
||||||
|
repository.findByTenantId(tenantId).ifPresent(config -> {
|
||||||
|
repository.delete(config);
|
||||||
|
log.info("Custom mail domain removed for club {}", tenantId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective FROM address for a club's outbound emails.
|
||||||
|
* Returns custom FROM if verified, otherwise platform default.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<String> getVerifiedFromAddress(UUID tenantId) {
|
||||||
|
return repository.findByTenantId(tenantId)
|
||||||
|
.filter(CustomMailDomain::isVerified)
|
||||||
|
.map(CustomMailDomain::getFromAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check DNS TXT records for the verification token.
|
||||||
|
*/
|
||||||
|
private boolean checkDnsTxtRecord(String domain, String expectedToken) {
|
||||||
|
try {
|
||||||
|
Hashtable<String, String> env = new Hashtable<>();
|
||||||
|
env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
|
||||||
|
DirContext ctx = new InitialDirContext(env);
|
||||||
|
|
||||||
|
Attributes attrs = ctx.getAttributes(domain, new String[]{"TXT"});
|
||||||
|
Attribute txtAttr = attrs.get("TXT");
|
||||||
|
|
||||||
|
if (txtAttr != null) {
|
||||||
|
NamingEnumeration<?> records = txtAttr.getAll();
|
||||||
|
while (records.hasMore()) {
|
||||||
|
String record = records.next().toString().replace("\"", "").trim();
|
||||||
|
if (record.equals(DNS_TXT_PREFIX + expectedToken)) {
|
||||||
|
ctx.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("DNS lookup failed for domain {}: {}", domain, e.getMessage());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractDomain(String email) {
|
||||||
|
if (email == null || !email.contains("@")) {
|
||||||
|
throw new IllegalArgumentException("Invalid email address: " + email);
|
||||||
|
}
|
||||||
|
return email.substring(email.indexOf("@") + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,25 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.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,
|
||||||
|
|||||||
+59
-60
@@ -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,
|
||||||
|
String title, String message, String actionLink) {
|
||||||
|
if (userEmail == null || userEmail.isBlank()) {
|
||||||
|
log.debug("No email for user {}, skipping email dispatch", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build push payload
|
// Check if user has EMAIL channel enabled
|
||||||
var payload = PushPayload.builder()
|
boolean emailEnabled = preferenceRepository
|
||||||
.title(notification.getTitle())
|
.findByUserIdAndChannel(userId, NotificationChannel.EMAIL)
|
||||||
.body(notification.getMessage())
|
.map(NotificationPreference::isEnabled)
|
||||||
.type(notification.getType().name())
|
.orElse(false); // Default: email is opt-in, so disabled if no preference exists
|
||||||
.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 (emailEnabled) {
|
||||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.WEB_PUSH)) {
|
emailService.sendNotificationEmail(userEmail, userName, title, message, actionLink);
|
||||||
var webTokens = deviceTokenRepository.findByUserIdAndPlatform(userId, DevicePlatform.WEB);
|
log.debug("Email dispatched to {} for notification: {}", userEmail, title);
|
||||||
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) {
|
} catch (Exception e) {
|
||||||
log.error("Error dispatching notification {} to user {}: {}", notification.getId(), userId, e.getMessage());
|
log.warn("Failed to dispatch email to user {}: {}", userId, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Web Push and Mobile Push would be dispatched here in future
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch method called by NotificationService after saving a notification.
|
||||||
|
* Fans out to email/push channels based on user preferences.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void dispatch(de.cannamanage.domain.entity.Notification notification, UUID userId) {
|
||||||
|
// For now, this is a no-op placeholder for push channels.
|
||||||
|
// Email dispatch requires user email which is not on the Notification entity.
|
||||||
|
// Full email integration will be triggered explicitly via dispatchToUser() when email is known.
|
||||||
|
log.debug("Dispatch hook called for notification {} to user {}", notification.getId(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user has email notifications enabled.
|
||||||
|
*/
|
||||||
|
public boolean isEmailEnabled(UUID userId) {
|
||||||
|
return preferenceRepository
|
||||||
|
.findByUserIdAndChannel(userId, NotificationChannel.EMAIL)
|
||||||
|
.map(NotificationPreference::isEnabled)
|
||||||
|
.orElse(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.PlanTier;
|
||||||
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
|
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||||
|
import de.cannamanage.service.repository.SubscriptionRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to enforce plan tier restrictions on features.
|
||||||
|
*
|
||||||
|
* Tier rules:
|
||||||
|
* - Forum: only Pro/Enterprise (Starter/Trial → 403)
|
||||||
|
* - Info Board: Starter = 5 posts/month, Pro/Enterprise = unlimited
|
||||||
|
* - Events: all tiers (no limit)
|
||||||
|
* - Notifications: all tiers
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class PlanTierService {
|
||||||
|
|
||||||
|
private static final int STARTER_INFO_BOARD_MONTHLY_LIMIT = 5;
|
||||||
|
private static final int STARTER_INFO_BOARD_CATEGORIES_LIMIT = 3;
|
||||||
|
private static final int PRO_FORUM_CATEGORIES_LIMIT = 5;
|
||||||
|
|
||||||
|
private final SubscriptionRepository subscriptionRepository;
|
||||||
|
|
||||||
|
public PlanTierService(SubscriptionRepository subscriptionRepository) {
|
||||||
|
this.subscriptionRepository = subscriptionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current plan tier for a club.
|
||||||
|
*/
|
||||||
|
public PlanTier getClubTier(UUID clubId) {
|
||||||
|
return subscriptionRepository.findByClubId(clubId)
|
||||||
|
.map(sub -> sub.getPlanTier())
|
||||||
|
.orElse(PlanTier.TRIAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club has access to the forum feature.
|
||||||
|
* Forum is only available for Pro and Enterprise tiers.
|
||||||
|
*
|
||||||
|
* @throws QuotaExceededException if the club's tier doesn't support forum
|
||||||
|
*/
|
||||||
|
public void requireForumAccess(UUID clubId) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
if (tier == PlanTier.STARTER || tier == PlanTier.TRIAL) {
|
||||||
|
log.debug("Forum access denied for club {} (tier: {})", clubId, tier);
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||||
|
"Forum ist nur im Pro- oder Enterprise-Tarif verfügbar. " +
|
||||||
|
"Bitte upgrade deinen Tarif, um das Forum zu nutzen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club can create a new forum category.
|
||||||
|
* Pro: max 5 categories, Enterprise: unlimited.
|
||||||
|
*
|
||||||
|
* @throws QuotaExceededException if the category limit is reached
|
||||||
|
*/
|
||||||
|
public void checkForumCategoryLimit(UUID clubId, int currentCategoryCount) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
if (tier == PlanTier.PRO && currentCategoryCount >= PRO_FORUM_CATEGORIES_LIMIT) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||||
|
"Im Pro-Tarif sind maximal " + PRO_FORUM_CATEGORIES_LIMIT + " Forum-Kategorien möglich. " +
|
||||||
|
"Upgrade auf Enterprise für unbegrenzte Kategorien."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club can create a new info board post this month.
|
||||||
|
* Starter: max 5/month, Pro/Enterprise: unlimited.
|
||||||
|
*
|
||||||
|
* @throws QuotaExceededException if the monthly post limit is reached
|
||||||
|
*/
|
||||||
|
public void checkInfoBoardPostLimit(UUID clubId, int postsThisMonth) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
if ((tier == PlanTier.STARTER || tier == PlanTier.TRIAL)
|
||||||
|
&& postsThisMonth >= STARTER_INFO_BOARD_MONTHLY_LIMIT) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||||
|
"Im Starter-Tarif sind maximal " + STARTER_INFO_BOARD_MONTHLY_LIMIT +
|
||||||
|
" Beiträge pro Monat im Schwarzen Brett möglich. " +
|
||||||
|
"Upgrade auf Pro für unbegrenzte Beiträge."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club can create a new info board category.
|
||||||
|
* Starter: max 3 categories, Pro/Enterprise: unlimited.
|
||||||
|
*
|
||||||
|
* @throws QuotaExceededException if the category limit is reached
|
||||||
|
*/
|
||||||
|
public void checkInfoBoardCategoryLimit(UUID clubId, int currentCategoryCount) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
if ((tier == PlanTier.STARTER || tier == PlanTier.TRIAL)
|
||||||
|
&& currentCategoryCount >= STARTER_INFO_BOARD_CATEGORIES_LIMIT) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||||
|
"Im Starter-Tarif sind maximal " + STARTER_INFO_BOARD_CATEGORIES_LIMIT +
|
||||||
|
" Kategorien im Schwarzen Brett möglich. Upgrade auf Pro für mehr."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club has Enterprise tier (for custom FROM address feature).
|
||||||
|
*
|
||||||
|
* @throws QuotaExceededException if the club is not Enterprise
|
||||||
|
*/
|
||||||
|
public void requireEnterpriseTier(UUID clubId) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
if (tier != PlanTier.ENTERPRISE) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.TIER_UPGRADE_REQUIRED,
|
||||||
|
"Diese Funktion ist nur im Enterprise-Tarif verfügbar."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club has at least Pro tier.
|
||||||
|
*/
|
||||||
|
public boolean isProOrHigher(UUID clubId) {
|
||||||
|
PlanTier tier = getClubTier(clubId);
|
||||||
|
return tier == PlanTier.PRO || tier == PlanTier.ENTERPRISE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a club has Enterprise tier.
|
||||||
|
*/
|
||||||
|
public boolean isEnterprise(UUID clubId) {
|
||||||
|
return getClubTier(clubId) == PlanTier.ENTERPRISE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for publishing real-time WebSocket events to connected clients.
|
||||||
|
* Uses STOMP topics for broadcast (per-club) and user queues for targeted messages.
|
||||||
|
*
|
||||||
|
* Topic structure:
|
||||||
|
* - /topic/club.{clubId}.infoboard — new info board posts
|
||||||
|
* - /topic/club.{clubId}.forum — new forum topics/replies
|
||||||
|
* - /topic/club.{clubId}.forum.{topicId} — replies to a specific topic
|
||||||
|
* - /topic/club.{clubId}.events — event RSVP changes
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class WebSocketEventPublisher {
|
||||||
|
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
public WebSocketEventPublisher(SimpMessagingTemplate messagingTemplate) {
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a new info board post event to all connected club members.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void publishInfoBoardPost(UUID clubId, UUID postId, String title, String category) {
|
||||||
|
String destination = "/topic/club." + clubId + ".infoboard";
|
||||||
|
Object payload = Map.of(
|
||||||
|
"type", "NEW_POST",
|
||||||
|
"postId", postId.toString(),
|
||||||
|
"title", title,
|
||||||
|
"category", category,
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSend(destination, payload);
|
||||||
|
log.debug("WebSocket event published to {}: new post '{}'", destination, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a new forum topic event.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void publishForumTopic(UUID clubId, UUID topicId, String title, String authorName) {
|
||||||
|
String destination = "/topic/club." + clubId + ".forum";
|
||||||
|
Object payload = Map.of(
|
||||||
|
"type", "NEW_TOPIC",
|
||||||
|
"topicId", topicId.toString(),
|
||||||
|
"title", title,
|
||||||
|
"authorName", authorName,
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSend(destination, payload);
|
||||||
|
log.debug("WebSocket event published to {}: new topic '{}'", destination, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a new forum reply event to topic subscribers.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void publishForumReply(UUID clubId, UUID topicId, UUID replyId, String authorName) {
|
||||||
|
// Notify the topic-specific channel
|
||||||
|
String topicDestination = "/topic/club." + clubId + ".forum." + topicId;
|
||||||
|
Object topicPayload = Map.of(
|
||||||
|
"type", "NEW_REPLY",
|
||||||
|
"topicId", topicId.toString(),
|
||||||
|
"replyId", replyId.toString(),
|
||||||
|
"authorName", authorName,
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSend(topicDestination, topicPayload);
|
||||||
|
|
||||||
|
// Also notify the general forum channel (for reply counts)
|
||||||
|
String forumDestination = "/topic/club." + clubId + ".forum";
|
||||||
|
Object forumPayload = Map.of(
|
||||||
|
"type", "TOPIC_UPDATED",
|
||||||
|
"topicId", topicId.toString(),
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSend(forumDestination, forumPayload);
|
||||||
|
log.debug("WebSocket reply event published for topic {}", topicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event RSVP change to the event creator.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void publishEventRsvpChange(UUID clubId, UUID eventId, String eventTitle,
|
||||||
|
String memberName, String rsvpStatus) {
|
||||||
|
String destination = "/topic/club." + clubId + ".events";
|
||||||
|
Object payload = Map.of(
|
||||||
|
"type", "RSVP_CHANGED",
|
||||||
|
"eventId", eventId.toString(),
|
||||||
|
"eventTitle", eventTitle,
|
||||||
|
"memberName", memberName,
|
||||||
|
"rsvpStatus", rsvpStatus,
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSend(destination, payload);
|
||||||
|
log.debug("WebSocket RSVP event published for event {}: {} → {}", eventId, memberName, rsvpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a notification to a specific user's queue.
|
||||||
|
*/
|
||||||
|
public void publishToUser(UUID userId, String title, String message, String link) {
|
||||||
|
String destination = "/queue/notifications";
|
||||||
|
Map<String, Object> payload = Map.of(
|
||||||
|
"type", "NOTIFICATION",
|
||||||
|
"title", title,
|
||||||
|
"message", message,
|
||||||
|
"link", link != null ? link : "",
|
||||||
|
"timestamp", Instant.now().toString()
|
||||||
|
);
|
||||||
|
messagingTemplate.convertAndSendToUser(userId.toString(), destination, payload);
|
||||||
|
log.debug("WebSocket notification sent to user {}: '{}'", userId, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -5,5 +5,6 @@ public enum QuotaViolationCode {
|
|||||||
QUOTA_EXCEEDED_DAILY,
|
QUOTA_EXCEEDED_DAILY,
|
||||||
QUOTA_EXCEEDED_MONTHLY,
|
QUOTA_EXCEEDED_MONTHLY,
|
||||||
HIGH_THC_RESTRICTED_UNDER_21,
|
HIGH_THC_RESTRICTED_UNDER_21,
|
||||||
BATCH_UNAVAILABLE
|
BATCH_UNAVAILABLE,
|
||||||
|
TIER_UPGRADE_REQUIRED
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.CustomMailDomain;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CustomMailDomainRepository extends JpaRepository<CustomMailDomain, UUID> {
|
||||||
|
|
||||||
|
Optional<CustomMailDomain> findByTenantId(UUID tenantId);
|
||||||
|
|
||||||
|
boolean existsByTenantId(UUID tenantId);
|
||||||
|
}
|
||||||
+13
@@ -31,6 +31,19 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
|||||||
*/
|
*/
|
||||||
List<Member> findByTenantIdAndUnder21True(UUID tenantId);
|
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(() ->
|
||||||
|
|||||||
Reference in New Issue
Block a user