Files
cannamanage/docs/sprint-7/cannamanage-sprint7-plan.md
Patrick Plate 706a6e257b feat(sprint7): Phase 1 — notifications enhancement + push infrastructure
Phase 1 (Notification Enhancement):
- Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION)
- Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM)
- Extended AuditEventType with Sprint 7 events
- Flyway V11: notification_sends + notification_send_recipients tables
- NotificationSend + NotificationSendRecipient entities
- NotificationSendRepository + NotificationSendRecipientRepository
- Extended NotificationService with sendBroadcast() and sendToSelected()
- NotificationComposeController (POST /compose, GET /sends)
- ComposeNotificationRequest DTO

Phase 1B (Push Infrastructure):
- Flyway V12: device_tokens + notification_preferences tables
- DeviceToken entity + DevicePlatform enum
- NotificationPreference entity + NotificationChannel enum
- DeviceTokenRepository + NotificationPreferenceRepository
- DeviceRegistrationService (register/unregister/list devices, max 10 per user)
- NotificationPreferenceService (get/create defaults, update, IN_APP always on)
- NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email)
- WebPushSender (VAPID-based, simplified for MVP)
- FcmPushSender (graceful degradation if not configured)
- PushPayload DTO
- DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key)
- NotificationPreferenceController (GET/PUT /preferences)
- ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL)
- TargetType enum (ALL, SELECTED)

Frontend:
- Updated sw.js with push event handler + notification click handler
- push-subscription.ts (subscribeToPush, unsubscribe, permission helpers)
- notification-compose.ts service (compose, sends, devices, preferences APIs)
- i18n keys (de.json + en.json) for compose, preferences, push, devices

Configuration:
- application-docker.properties: VAPID + FCM push config properties
- MemberRepository: added findAllActiveUserIds() for broadcast
2026-06-13 19:25:19 +02:00

84 KiB

Sprint 7 Implementation Plan — Communication & Community

Date: 2026-06-13 Author: Patrick Plate / Lumen (Architect) Status: Draft v2 Based on: cannamanage-sprint7-analysis.md Sprint Goal: Admin Notifications + Info Board + Club Event Calendar + Club-Internal Forum (MVP)


Overview

This plan implements four communication features in six phases:

graph TD
    P1[Phase 1: Admin Notifications] --> P2[Phase 2: Info Board]
    P2 --> P2_5[Phase 2.5: Club Event Calendar]
    P1 --> P3[Phase 3: Forum MVP]
    P2_5 --> P4[Phase 4: Integration and Polish]
    P3 --> P4
    P4 --> P5[Phase 5: Testing and QA]

    subgraph Existing
        NS[NotificationService]
        WS[WebSocket/STOMP]
        SP[StaffPermission enum]
        AU[AuditService]
    end

    NS --> P1
    WS --> P1
    SP --> P1
    SP --> P2
    SP --> P2_5
    SP --> P3
    AU --> P4

Phase 1: Admin Notifications Enhancement

Extends the existing notification infrastructure with admin-composed messages, broadcast/targeted delivery, and a compose UI.

Step 1.1 — Extend NotificationType enum

Files to modify:

Approach: Add new enum constants for Sprint 7 features.

public enum NotificationType {
    QUOTA_WARNING,
    BATCH_RECALLED,
    DISTRIBUTION_RECORDED,
    SUBSCRIPTION_EXPIRING,
    // Sprint 7:
    ADMIN_MESSAGE,
    INFO_BOARD_POST,
    FORUM_REPLY,
    FORUM_MENTION
}

Dependencies: None (leaf change) Acceptance criteria: Enum compiles, no existing functionality breaks.


Step 1.2 — Extend StaffPermission enum

Files to modify:

Approach: Add three new permissions for Sprint 7.

SEND_NOTIFICATIONS,      // Compose + send admin notifications
MANAGE_INFO_BOARD,       // Create/edit/pin/archive info board posts
MODERATE_FORUM           // Delete posts, lock topics, resolve reports

Dependencies: None Acceptance criteria: Existing staff permission checks unaffected. New values available for assignment.


Step 1.3 — Extend AuditEventType enum

Files to modify:

Approach: Add audit events for all Sprint 7 features.

// Notification events
NOTIFICATION_BROADCAST_SENT,

// Info Board events
INFO_BOARD_POST_CREATED,
INFO_BOARD_POST_EDITED,
INFO_BOARD_POST_PINNED,
INFO_BOARD_POST_ARCHIVED,

// Forum events
FORUM_TOPIC_CREATED,
FORUM_TOPIC_LOCKED,
FORUM_TOPIC_DELETED,
FORUM_REPLY_DELETED,
FORUM_REPORT_RESOLVED

Dependencies: None Acceptance criteria: Compiles, audit service can log all new event types.


Step 1.4 — Flyway migration V11: notification_sends tables

Files to create:

  • cannamanage-api/src/main/resources/db/migration/V11__notification_sends.sql

SQL:

CREATE TABLE notification_sends (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title VARCHAR(255) NOT NULL,
    message TEXT NOT NULL,
    link VARCHAR(500),
    author_id UUID NOT NULL,
    target_type VARCHAR(20) NOT NULL,
    target_count INTEGER NOT NULL,
    read_count INTEGER NOT NULL DEFAULT 0,
    sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    tenant_id UUID NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE TABLE notification_send_recipients (
    send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
    user_id UUID NOT NULL,
    notification_id UUID REFERENCES notifications(id),
    PRIMARY KEY (send_id, user_id)
);

CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);

Dependencies: Existing notifications table must exist (V-series migration ordering) Acceptance criteria: Migration runs clean on dev PostgreSQL. Tables created with correct foreign keys.


Step 1.5 — Backend: NotificationSend and NotificationSendRecipient entities

Files to create:

  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationSend.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationSendRecipient.java

Approach:

  • NotificationSend extends AbstractTenantEntity, maps notification_sends table
  • Fields: title, message, link, authorId, targetType (enum: ALL, SELECTED), targetCount, readCount, sentAt
  • NotificationSendRecipient is an @IdClass entity for the join table

Dependencies: Step 1.4 (migration) Acceptance criteria: JPA mapping validates at startup. CRUD operations work.


Step 1.6 — Backend: NotificationSendRepository

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/repository/NotificationSendRepository.java

Approach: Standard Spring Data JPA repository. Methods:

  • findAllByOrderBySentAtDesc() (paginated)
  • Custom query to update readCount based on associated notifications' read status

Dependencies: Step 1.5 Acceptance criteria: Repository methods return correct data in tests.


Step 1.7 — Backend: Extend NotificationService with broadcast

Files to modify:

Approach: Add methods:

  • sendBroadcast(String title, String message, String link, UUID authorId) — creates notification for all club members
  • sendToSelected(String title, String message, String link, UUID authorId, List<UUID> recipientIds) — targeted send

Implementation:

  1. Query all active members of the current tenant (or the selected subset)
  2. Batch-INSERT notifications using JdbcTemplate.batchUpdate() for performance (500+ members)
  3. Create NotificationSend record with metadata
  4. Create NotificationSendRecipient join records
  5. Push via WebSocket using topic-based STOMP destination (/topic/club/{tenantId}/notifications). All tenant members subscribe to this single topic — no need for sequential per-user sends. For targeted notifications (SELECTED), use user-specific destinations (/user/{userId}/queue/notifications). This avoids the O(n) bottleneck for broadcasts.
  6. Return the NotificationSend record

Dependencies: Step 1.5, Step 1.6, existing MemberRepository Acceptance criteria: Broadcast to 100 members completes in <2s. Each member receives WebSocket push and DB record.


Step 1.8 — Backend: NotificationComposeController

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/NotificationComposeController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/ComposeNotificationRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/NotificationSendResponse.java

Endpoints:

Method Path Permission Description
POST /api/v1/notifications/compose SEND_NOTIFICATIONS Compose + send
GET /api/v1/notifications/sends SEND_NOTIFICATIONS List sent (paginated)
GET /api/v1/notifications/sends/{id} SEND_NOTIFICATIONS Details + per-recipient read status

Request DTO:

public record ComposeNotificationRequest(
    @NotBlank String title,
    @NotBlank String message,
    String link,
    @NotNull TargetType targetType,  // ALL or SELECTED
    List<UUID> recipientIds          // required if SELECTED
) {}

Dependencies: Step 1.7, StaffPermissionChecker Acceptance criteria: Permission check works. 401 for unauthorized staff. 200 on successful send.


Step 1.9 — Frontend: Notification compose page

Files to create:

  • cannamanage-frontend/src/app/(dashboard-layout)/settings/notifications/page.tsx — Notification history/compose parent
  • cannamanage-frontend/src/app/(dashboard-layout)/settings/notifications/compose/page.tsx — Compose form
  • cannamanage-frontend/src/services/notification-compose.ts — API service

Approach:

  • Compose form: recipient selector (radio: All / Selected → multi-select member picker), title input, message textarea (markdown), optional link, preview toggle, send button
  • History table: lists sent notifications with title, target count, read count, sent date
  • Click on row → detail view with per-recipient read status
  • Use React Query useMutation for send action with optimistic feedback

Dependencies: Step 1.8 (API must exist), existing members service for member list Acceptance criteria: Admin can compose and send notification. History shows sent items with read stats.


Step 1.10 — Frontend: Update navigation

Files to modify:

Approach: Add navigation items for Sprint 7 features:

{
  title: "Communication",
  items: [
    { title: "Info Board", href: "/info-board", iconName: "Megaphone" },
    { title: "Forum", href: "/forum", iconName: "MessageSquare" },
    { title: "Benachrichtigungen", href: "/settings/notifications", iconName: "Bell" },
  ],
}

Dependencies: None (can be added early, pages will 404 until built) Acceptance criteria: Navigation renders correctly. Links work once pages exist.


Phase 1B: Multi-Channel Push Notification Architecture

Extends the notification system from Phase 1 with Web Push, Mobile Push (FCM/APNs), device registration, and per-user channel preferences. Designed to support a member mobile app in Sprint 8 while enabling Web Push immediately with the existing PWA service worker.

graph TD
    NS[NotificationService.sendBroadcast] --> FO[NotificationDispatchService]
    FO --> CH_INAPP[In-App Channel - WebSocket/STOMP]
    FO --> CH_EMAIL[Email Channel - existing EmailService]
    FO --> CH_WEBPUSH[Web Push Channel - VAPID]
    FO --> CH_MOBILE[Mobile Push Channel - FCM]

    subgraph Device Registry
        DR[DeviceToken entity]
        DR --> WEB[platform=WEB]
        DR --> IOS[platform=IOS]
        DR --> ANDROID[platform=ANDROID]
    end

    subgraph User Preferences
        NP[NotificationPreference entity]
        NP --> IN_APP[channel=IN_APP - always on]
        NP --> EMAIL_PREF[channel=EMAIL - opt-in]
        NP --> WEB_PUSH_PREF[channel=WEB_PUSH - opt-in]
        NP --> MOBILE_PUSH_PREF[channel=MOBILE_PUSH - opt-in]
    end

    CH_WEBPUSH --> DR
    CH_MOBILE --> DR
    FO --> NP

Architectural Decisions

Decision Rationale Alternatives Considered
Web Push via VAPID keys + existing sw.js PWA service worker already registered; Web Push works immediately without native app Polling (too slow), Server-Sent Events (no offline support)
FCM for cross-platform mobile push Single API covers both Android (native) and iOS (via APNs proxy). Industry standard. Direct APNs + custom Android (more complexity, two integrations)
Device tokens stored per-user, multiple allowed Users may have phone + tablet + browser; each needs its own push subscription Single token per user (would lose multi-device)
Channel preferences per-user, per-channel Users control what they receive where. GDPR-friendly. Global opt-in/out only (too coarse)
Dispatch service is channel-agnostic Adding SMS/WhatsApp later = just a new adapter. No core changes. Hardcoded channels in NotificationService (fragile)
IN_APP channel always enabled, cannot be disabled Core functionality; users expect in-app notifications All channels optional (could miss critical alerts)

Step 1.11 — Flyway migration V12: Device tokens and notification preferences

Files to create:

  • cannamanage-api/src/main/resources/db/migration/V12__push_notification_infra.sql

SQL:

-- Device token registry (Web Push subscriptions + mobile push tokens)
CREATE TABLE device_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    platform VARCHAR(20) NOT NULL,  -- WEB, IOS, ANDROID
    token TEXT NOT NULL,            -- Push subscription JSON (Web) or FCM token (mobile)
    device_name VARCHAR(100),       -- e.g. "Chrome on MacBook", "iPhone 15"
    last_used_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    tenant_id UUID NOT NULL,
    UNIQUE(user_id, token)
);

CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);

-- Per-user notification channel preferences
CREATE TABLE notification_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    channel VARCHAR(20) NOT NULL,   -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
    enabled BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    tenant_id UUID NOT NULL,
    UNIQUE(user_id, channel)
);

CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);

Dependencies: V11 (notification_sends) must run first. Note: Sequential migration numbering — V11 → V12 → V13 → V14 → V15 → V16 → V17. Acceptance criteria: Migration succeeds. Tables created with correct unique constraints.


Step 1.12 — Backend: Device token and preference entities

Files to create:

  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/DeviceToken.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationPreference.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/DevicePlatform.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationChannel.java

Approach:

// DevicePlatform enum
public enum DevicePlatform { WEB, IOS, ANDROID }

// NotificationChannel enum
public enum NotificationChannel { IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH }

// DeviceToken entity
@Entity @Table(name = "device_tokens")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class DeviceToken extends AbstractTenantEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private DevicePlatform platform;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String token;  // Web Push: JSON subscription object; Mobile: FCM registration token

    private String deviceName;
    private Instant lastUsedAt;
}

// NotificationPreference entity
@Entity @Table(name = "notification_preferences")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class NotificationPreference extends AbstractTenantEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private NotificationChannel channel;

    @Column(nullable = false)
    private boolean enabled;
}

Dependencies: Step 1.11 Acceptance criteria: Entities validate at startup. CRUD operations work. Unique constraint enforced.


Step 1.13 — Backend: Device registration API

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/DeviceRegistrationController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/RegisterDeviceRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/DeviceTokenResponse.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/DeviceTokenRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/DeviceRegistrationService.java

Endpoints:

Method Path Access Description
POST /api/v1/notifications/devices Any authenticated Register device token
GET /api/v1/notifications/devices Any authenticated List user's registered devices
DELETE /api/v1/notifications/devices/{id} Owner only Unregister device

Request DTO:

public record RegisterDeviceRequest(
    @NotNull DevicePlatform platform,
    @NotBlank String token,           // Web Push subscription JSON or FCM token
    String deviceName                  // optional friendly name
) {}

Service logic:

  • On register: upsert by (user_id, token) — if token already exists, update lastUsedAt
  • On unregister: delete token record
  • Auto-cleanup: scheduled job removes tokens not used in 90 days (stale subscriptions)
  • Limit: max 10 devices per user (prevent abuse)

Dependencies: Step 1.12 Acceptance criteria: Portal member can register a Web Push subscription. Same subscription re-registered updates timestamp. Unregister removes token.


Step 1.14 — Backend: Notification preferences API

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/NotificationPreferenceController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/NotificationPreferenceResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/notification/UpdatePreferenceRequest.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/NotificationPreferenceRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/NotificationPreferenceService.java

Endpoints:

Method Path Access Description
GET /api/v1/notifications/preferences Any authenticated Get user's channel preferences
PUT /api/v1/notifications/preferences Any authenticated Update preferences

Service logic:

  • On first access: auto-create default preferences (IN_APP=true, EMAIL=false, WEB_PUSH=false, MOBILE_PUSH=false)
  • IN_APP can never be set to false (server-side enforcement)
  • Response includes which channels have registered devices (so UI can show "enable push" prompt only when device is registered)
  • GDPR consent record (Art. 7(1)): When a user enables WEB_PUSH, MOBILE_PUSH, or EMAIL, a consent record is stored in the existing consents table with: type=NOTIFICATION_PUSH or type=NOTIFICATION_EMAIL, granted_at=Instant.now(), scope="push notifications via [channel]", method="explicit opt-in via preferences UI". When disabled, the consent is revoked (set revoked_at). This creates the audit trail required by GDPR Art. 7(1) — browser's Notification.requestPermission() alone is NOT sufficient as a legal consent record.

Dependencies: Step 1.12, existing consents table + ConsentService Acceptance criteria: User gets default preferences on first access. Can enable/disable EMAIL, WEB_PUSH, MOBILE_PUSH. Cannot disable IN_APP. Enabling a push channel creates a GDPR consent record in the consents table. Disabling revokes the consent with timestamp.


Step 1.15 — Backend: NotificationDispatchService (multi-channel fan-out)

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/NotificationDispatchService.java
  • cannamanage-service/src/main/java/de/cannamanage/service/push/WebPushSender.java
  • cannamanage-service/src/main/java/de/cannamanage/service/push/FcmPushSender.java
  • cannamanage-service/src/main/java/de/cannamanage/service/push/PushPayload.java

Approach:

@Slf4j
@Service
public class NotificationDispatchService {

    // Called after NotificationService creates the in-app notification
    public void dispatch(Notification notification, User user) {
        var prefs = preferenceService.getPreferences(user.getId());

        // IN_APP: already handled by NotificationService (WebSocket push)

        // EMAIL: if enabled + user has verified email
        if (prefs.isEmailEnabled() && user.getEmail() != null) {
            emailService.sendNotificationEmail(user.getEmail(), notification);
        }

        // WEB_PUSH: if enabled + has registered Web Push subscriptions
        if (prefs.isWebPushEnabled()) {
            var webTokens = deviceTokenRepo.findByUserIdAndPlatform(user.getId(), WEB);
            webTokens.forEach(dt -> webPushSender.send(dt.getToken(), toPushPayload(notification)));
        }

        // MOBILE_PUSH: if enabled + has registered mobile tokens
        if (prefs.isMobilePushEnabled()) {
            var mobileTokens = deviceTokenRepo.findByUserIdAndPlatformIn(user.getId(), List.of(IOS, ANDROID));
            mobileTokens.forEach(dt -> fcmPushSender.send(dt.getToken(), toPushPayload(notification)));
        }
    }
}

Unified push payload:

@Data @Builder
public class PushPayload {
    private String title;
    private String body;
    private String type;        // NotificationType enum value
    private String icon;        // "/icons/icon-192.png"
    private String badge;       // "/icons/icon-192.png"
    private String url;         // click action URL
    private Map<String, String> data;  // custom data (e.g. distributionId, topicId)
}

Integration with existing NotificationService:

  • Modify NotificationService.sendBroadcast() and sendToSelected() to call dispatchService.dispatch(notification, user) after persisting each notification
  • Use @Async for dispatch to avoid blocking the main transaction

Dependencies: Steps 1.13, 1.14, existing NotificationService Acceptance criteria: When a notification is created, it fans out to all enabled channels. Disabled channels are skipped. Missing device tokens gracefully handled (no error).


Step 1.16 — Backend + Frontend: Web Push (VAPID)

Backend files to create/modify:

  • cannamanage-service/src/main/java/de/cannamanage/service/push/WebPushSender.java (implementation)

Configuration (application.properties):

# Web Push VAPID keys (generate once, store in env)
push.vapid.public-key=${VAPID_PUBLIC_KEY}
push.vapid.private-key=${VAPID_PRIVATE_KEY}
push.vapid.subject=mailto:admin@cannamanage.de

VAPID key generation (one-time setup):

npx web-push generate-vapid-keys

WebPushSender implementation:

  • Uses nl.martijndwars:web-push-java library (or com.interaso:webpush) for Java Web Push
  • Accepts the subscription JSON stored in DeviceToken.token, parses endpoint + keys
  • Sends push payload encrypted per Web Push protocol

Frontend files to modify:

  • sw.js — Add push event handler + notification click handler
  • New: cannamanage-frontend/src/lib/push-subscription.ts — VAPID subscription helper
  • New: cannamanage-frontend/src/components/push-permission-prompt.tsx — Permission request UI

Service worker additions (sw.js):

// Handle incoming push messages
self.addEventListener("push", (event) => {
  const data = event.data ? event.data.json() : {}
  const options = {
    body: data.body || "Neue Benachrichtigung",
    icon: data.icon || "/icons/icon-192.png",
    badge: "/icons/icon-192.png",
    tag: data.type || "default",
    data: { url: data.url || "/portal/notifications", ...data.data },
    actions: data.actions || [{ action: "open", title: "Anzeigen" }],
    vibrate: [100, 50, 100],
  }
  event.waitUntil(self.registration.showNotification(data.title || "CannaManage", options))
})

// Handle notification click
self.addEventListener("notificationclick", (event) => {
  event.notification.close()
  const url = event.notification.data?.url || "/portal/notifications"
  event.waitUntil(
    clients.matchAll({ type: "window" }).then((windowClients) => {
      for (const client of windowClients) {
        if (client.url.includes(url) && "focus" in client) return client.focus()
      }
      return clients.openWindow(url)
    })
  )
})

Frontend push subscription flow:

// push-subscription.ts
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
  if (!("serviceWorker" in navigator) || !("PushManager" in window)) return null

  const registration = await navigator.serviceWorker.ready
  const permission = await Notification.requestPermission()
  if (permission !== "granted") return null

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
  })

  // Send subscription to backend
  await fetch("/api/v1/notifications/devices", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      platform: "WEB",
      token: JSON.stringify(subscription),
      deviceName: navigator.userAgent.includes("Mobile") ? "Mobile Browser" : "Desktop Browser",
    }),
  })

  return subscription
}

Push permission prompt (delayed — not on first login):

  • NOT shown on first login. Instead, shown after the user has experienced value: triggered on second portal login OR after first meaningful interaction (e.g., after viewing a distribution, after reading an info board post). This avoids aggressive prompting before users understand what the platform does.
  • "Möchtest du Push-Benachrichtigungen aktivieren? Du wirst über neue Ankündigungen, Abgabetermine und Forum-Antworten informiert." (German explanation of practical value)
  • Accept/Decline buttons
  • On accept: calls subscribeToPush(), updates notification preferences to WEB_PUSH=true, stores GDPR consent record
  • On decline: remembers in localStorage, doesn't ask again for 30 days
  • Implementation: localStorage.getItem('cm_login_count') incremented on each portal login; prompt shown when count >= 2

Dependencies: Step 1.15 (dispatch service), Step 1.13 (device registration API) Acceptance criteria: Portal member grants push permission → subscription registered on backend → admin sends broadcast → native browser notification appears on member's device.


Step 1.17 — Backend: FCM Mobile Push (fully implemented)

Decision (2026-06-13): Firebase project is set up in Sprint 7. FcmPushSender is NOT conditional or deferred — it is fully implemented now. The native member app (Sprint 8) will simply call the existing device registration endpoint.

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/push/FcmPushSender.java (implementation)
  • cannamanage-api/src/main/resources/firebase-service-account.json (gitignored, loaded from env)

Firebase project setup (Sprint 7 deliverable):

  1. Create Firebase project "cannamanage-prod" in Firebase Console
  2. Enable Cloud Messaging API
  3. Generate service account key JSON
  4. Store in production: environment variable GOOGLE_APPLICATION_CREDENTIALS pointing to mounted secret
  5. Store in dev: local file at ~/.cannamanage/firebase-service-account.json

Configuration (application.properties):

# Firebase Cloud Messaging — ALWAYS ACTIVE in production
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}

FcmPushSender implementation:

@Slf4j
@Service
public class FcmPushSender {

    private FirebaseMessaging messaging;

    @Value("${push.fcm.credentials-path:}")
    private String credentialsPath;

    @PostConstruct
    void init() {
        if (credentialsPath == null || credentialsPath.isBlank()) {
            log.warn("FCM credentials not configured — push notifications to mobile devices disabled");
            return;
        }
        try {
            FirebaseOptions options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(
                    new FileInputStream(credentialsPath)))
                .build();
            if (FirebaseApp.getApps().isEmpty()) {
                FirebaseApp.initializeApp(options);
            }
            messaging = FirebaseMessaging.getInstance();
            log.info("Firebase Cloud Messaging initialized successfully");
        } catch (Exception e) {
            log.error("Failed to initialize FCM: {}", e.getMessage());
        }
    }

    public void send(String fcmToken, PushPayload payload) {
        if (messaging == null) {
            log.debug("FCM not initialized — skipping push to token {}", fcmToken.substring(0, 10));
            return;
        }

        Message message = Message.builder()
            .setToken(fcmToken)
            .setNotification(Notification.builder()
                .setTitle(payload.getTitle())
                .setBody(payload.getBody())
                .setImage(payload.getIcon())
                .build())
            .putAllData(payload.getData())
            .setAndroidConfig(AndroidConfig.builder()
                .setPriority(AndroidConfig.Priority.HIGH)
                .build())
            .setApnsConfig(ApnsConfig.builder()
                .setAps(Aps.builder().setSound("default").setBadge(1).build())
                .build())
            .build();

        try {
            messaging.send(message);
        } catch (FirebaseMessagingException e) {
            if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
                deviceTokenRepo.deleteByToken(fcmToken);
                log.warn("Removed expired FCM token");
            } else if (isTransientError(e)) {
                // Retry with exponential backoff: 1s, 2s, 4s (max 3 attempts)
                retryWithBackoff(() -> messaging.send(message), 3, fcmToken);
            } else {
                log.error("FCM send failed permanently: {}", e.getMessage());
            }
        }
    }
}

Key points:

  • No @ConditionalOnProperty — FCM bean is always created; gracefully degrades if credentials missing
  • Firebase project created in Sprint 7 as part of initial setup
  • Handles UNREGISTERED error by auto-removing stale tokens
  • Supports both Android (high priority) and iOS (via APNs proxy in FCM)
  • The native member app (Sprint 8) calls POST /api/v1/notifications/devices with platform=IOS/ANDROID
  • Backend dispatch + send logic is fully operational NOW

Maven dependency:

<dependency>
    <groupId>com.google.firebase</groupId>
    <artifactId>firebase-admin</artifactId>
    <version>9.3.0</version>
</dependency>

Dependencies: Step 1.15 (dispatch service) Acceptance criteria: FCM sender initializes at application startup with valid credentials. Sends push to registered FCM tokens. Handles token expiry by auto-removal. Gracefully degrades in dev without credentials (logs warning, skips send).


Phase 2: Info Board (Schwarzes Brett)

Implements a one-to-many announcement system where staff posts and members read.

Step 2.1 — Flyway migration V13: Info Board tables

Files to create:

  • cannamanage-api/src/main/resources/db/migration/V13__info_board.sql

SQL: (from analysis document section 1.4)

  • info_board_categories — name, color, sort_order, tenant_id
  • info_board_posts — title, body (TEXT), category_id, author_id, pinned, archived, published_at, tenant_id
  • info_board_attachments — post_id, filename, content_type, size_bytes, storage_path, tenant_id
  • Indexes on tenant+archived+pinned+published_at, and on category_id

Dependencies: Phase 1 complete (V11 must run first) Acceptance criteria: Migration succeeds. Tables visible in DB with correct constraints.


Step 2.2 — Backend: Info Board entities

Files to create:

  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InfoBoardCategory.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InfoBoardPost.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InfoBoardAttachment.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/InfoBoardCategoryType.java

Approach:

  • All extend AbstractTenantEntity
  • InfoBoardPost has @ManyToOne to InfoBoardCategory, @OneToMany to InfoBoardAttachment
  • InfoBoardPost adds updatedAt field with @PreUpdate lifecycle hook
  • Enum InfoBoardCategoryType: EVENT, RULE, GENERAL, MAINTENANCE, STRAIN_NEWS

Dependencies: Step 2.1 Acceptance criteria: Entities map correctly. Hibernate validates schema at startup.


Step 2.3 — Backend: Info Board repositories

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/repository/InfoBoardCategoryRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/InfoBoardPostRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/InfoBoardAttachmentRepository.java

Key methods:

  • InfoBoardPostRepository.findByArchivedFalseOrderByPinnedDescPublishedAtDesc(Pageable) — listing with pinned-first sort
  • InfoBoardPostRepository.findByCategoryIdAndArchivedFalse(UUID, Pageable) — category filter
  • InfoBoardCategoryRepository.findAllByOrderBySortOrderAsc() — category listing

Dependencies: Step 2.2 Acceptance criteria: Queries return correct ordering (pinned first, then by date).


Step 2.4 — Backend: InfoBoardService

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/InfoBoardService.java

Methods:

Method Description
createCategory(name, color, sortOrder) Create category
updateCategory(id, name, color, sortOrder) Update category
listCategories() List all categories for tenant
createPost(title, body, categoryId, authorId) Create + trigger notification
updatePost(id, title, body, categoryId) Update existing post
togglePin(id) Pin/unpin
archivePost(id) Soft-archive
listPosts(categoryId, includeArchived, Pageable) Paginated listing
getPost(id) Single post with attachments

On createPost: auto-send notification of type INFO_BOARD_POST to all club members (reuses Phase 1 broadcast infra).

Dependencies: Step 2.3, NotificationService (Phase 1) Acceptance criteria: Full CRUD works. Post creation triggers member notification.


Step 2.5 — Backend: InfoBoardController

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/InfoBoardController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/CreateInfoBoardPostRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/InfoBoardPostResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/InfoBoardCategoryResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/CreateCategoryRequest.java

Endpoints:

Method Path Permission Description
GET /api/v1/info-board/categories Any authenticated List categories
POST /api/v1/info-board/categories ADMIN only Create category
PUT /api/v1/info-board/categories/{id} ADMIN only Update category
GET /api/v1/info-board/posts Any authenticated List posts (paginated, filterable by category)
GET /api/v1/info-board/posts/{id} Any authenticated Single post detail
POST /api/v1/info-board/posts MANAGE_INFO_BOARD Create post
PUT /api/v1/info-board/posts/{id} MANAGE_INFO_BOARD Update post
PUT /api/v1/info-board/posts/{id}/pin MANAGE_INFO_BOARD Toggle pin
PUT /api/v1/info-board/posts/{id}/archive MANAGE_INFO_BOARD Archive

Attachment endpoints deferred to a sub-step (file upload adds complexity).

Dependencies: Step 2.4 Acceptance criteria: All endpoints return correct HTTP status. Permission checks enforced.


Step 2.6 — Backend: File upload for attachments

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/FileStorageService.java
  • Extend InfoBoardController with attachment endpoints

Approach:

  • Store files on local filesystem: /data/attachments/{tenantId}/{postId}/{filename}
  • Max file size: 10 MB (configured in application.properties)
  • Allowed types: image/png, image/jpeg, application/pdf
  • On upload: validate type + size, write to disk, create InfoBoardAttachment record
  • Download endpoint streams file from disk with correct Content-Type

Dependencies: Step 2.5 Acceptance criteria: Upload 5 MB PDF succeeds. Download returns same file. Oversized/wrong-type files rejected with 400.


Step 2.7 — Frontend: Info Board admin page

Files to create:

  • cannamanage-frontend/src/app/(dashboard-layout)/info-board/page.tsx — Post list + management
  • cannamanage-frontend/src/app/(dashboard-layout)/info-board/new/page.tsx — Create/edit post form
  • cannamanage-frontend/src/services/info-board.ts — API service
  • cannamanage-frontend/src/components/info-board/post-card.tsx — Post card component
  • cannamanage-frontend/src/components/info-board/category-badge.tsx — Colored category badge
  • cannamanage-frontend/src/components/rich-text-editor.tsx — Reusable WYSIWYG editor (Tiptap)

Approach:

  • Post list: filterable by category (tabs), shows title/excerpt/author/date/pinned-badge
  • Actions dropdown per card: Edit, Pin/Unpin, Archive
  • Create form: title input, category select, WYSIWYG rich-text editor (Tiptap) with formatting toolbar (bold, italic, headings, lists, links, code blocks), optional attachment upload (drag-and-drop zone). Target audience is non-technical cannabis club members — raw markdown is inappropriate. Tiptap outputs HTML stored in the body column (TEXT type). Renders via dangerouslySetInnerHTML with DOMPurify sanitization on display.
  • Uses shadcn/ui components: Card, Button, Tabs, Dialog, DropdownMenu
  • React Query: useQuery for listing, useMutation for create/update/pin/archive

Dependencies: Step 2.5 (API), Step 2.6 (attachments) Acceptance criteria: Admin can create, edit, pin, and archive posts. Category filter works. Attachments uploadable.


Step 2.8 — Frontend: Portal announcements view

Files to create:

  • cannamanage-frontend/src/app/(portal-layout)/portal/announcements/page.tsx — Full announcements page
  • cannamanage-frontend/src/components/portal/announcements-widget.tsx — Dashboard widget (latest 3 posts)

Approach:

  • Portal dashboard: widget shows latest 3 non-archived posts (pinned first) with "View All" link
  • Full page: paginated list of posts, category filter, markdown rendered body
  • Pinned indicator (📌), category color badge
  • Read-only for portal members (no edit/archive actions)

Dependencies: Step 2.7 (shared components like post-card, category-badge) Acceptance criteria: Portal members see announcements. Pinned posts appear first. Category filter works.


Phase 2.5: Club Event Calendar

Implements a club-level event system with RSVP, calendar view, and iCal export. Integrates with the Phase 1 notification infrastructure for event reminders.

graph TD
    EV[ClubEvent Entity] --> ES[EventService]
    ES --> EC[EventController]
    ES --> NS[NotificationService - reminders]
    ES --> IC[iCal Generator]
    RSVP[EventRsvp Entity] --> ES
    EC --> FE_ADMIN[Admin Calendar Page]
    EC --> FE_PORTAL[Portal Events Widget]

Step 2.5.1 — Extend enums for events

Files to modify:

Approach: Add new enum constants:

// NotificationType — add:
EVENT_CREATED,
EVENT_REMINDER,
EVENT_CANCELLED

// AuditEventType — add:
EVENT_CREATED,
EVENT_UPDATED,
EVENT_CANCELLED,
EVENT_RSVP

// StaffPermission — reuse MANAGE_INFO_BOARD for events (shared "club communication" scope)
// No new permission needed — events are part of club communication management

Dependencies: None (leaf change) Acceptance criteria: Enums compile, no existing functionality breaks.


Step 2.5.2 — Flyway migration V14: Event tables

Files to create:

  • cannamanage-api/src/main/resources/db/migration/V14__club_events.sql

SQL:

-- Club events
CREATE TABLE club_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    club_id UUID NOT NULL REFERENCES clubs(id),
    title VARCHAR(200) NOT NULL,
    description TEXT,
    event_type VARCHAR(50) NOT NULL,  -- MEETING, HARVEST_FESTIVAL, BOARD_MEETING, WORKSHOP, OTHER
    start_at TIMESTAMP WITH TIME ZONE NOT NULL,
    end_at TIMESTAMP WITH TIME ZONE,
    location VARCHAR(300),
    max_attendees INTEGER,
    is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
    recurrence_rule VARCHAR(100),  -- WEEKLY, BIWEEKLY, MONTHLY
    recurrence_end_date DATE,
    created_by UUID NOT NULL REFERENCES users(id),
    tenant_id UUID NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);

-- Event RSVPs
CREATE TABLE event_rsvps (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
    member_id UUID NOT NULL REFERENCES members(id),
    status VARCHAR(20) NOT NULL,  -- ACCEPTED, DECLINED, MAYBE
    responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    tenant_id UUID NOT NULL,
    UNIQUE(event_id, member_id)
);

CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);

Dependencies: V12 (info_board) must run first Acceptance criteria: Migration succeeds. Tables created with correct foreign keys, unique constraints, and indexes.


Step 2.5.3 — Backend: Event entities and enums

Files to create:

  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ClubEvent.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/EventRsvp.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/EventType.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RsvpStatus.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/RecurrenceRule.java

Approach:

// EventType enum
public enum EventType {
    MEETING,           // Mitgliederversammlung
    HARVEST_FESTIVAL,  // Erntefest
    BOARD_MEETING,     // Vorstandssitzung
    WORKSHOP,          // Workshop
    OTHER              // Sonstiges
}

// RsvpStatus enum
public enum RsvpStatus { ACCEPTED, DECLINED, MAYBE }

// RecurrenceRule enum
public enum RecurrenceRule { WEEKLY, BIWEEKLY, MONTHLY }

// ClubEvent entity
@Entity @Table(name = "club_events")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class ClubEvent extends AbstractTenantEntity {
    @Column(nullable = false, length = 200)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(name = "event_type", nullable = false, length = 50)
    private EventType eventType;

    @Column(name = "start_at", nullable = false)
    private Instant startAt;

    @Column(name = "end_at")
    private Instant endAt;

    @Column(length = 300)
    private String location;

    private Integer maxAttendees;

    @Column(name = "is_recurring", nullable = false)
    private boolean recurring;

    @Enumerated(EnumType.STRING)
    @Column(name = "recurrence_rule", length = 100)
    private RecurrenceRule recurrenceRule;

    @Column(name = "recurrence_end_date")
    private LocalDate recurrenceEndDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "created_by", nullable = false)
    private User createdBy;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "club_id", nullable = false)
    private Club club;

    @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<EventRsvp> rsvps = new ArrayList<>();
}

// EventRsvp entity
@Entity @Table(name = "event_rsvps",
    uniqueConstraints = @UniqueConstraint(columnNames = {"event_id", "member_id"}))
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class EventRsvp extends AbstractTenantEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "event_id", nullable = false)
    private ClubEvent event;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private RsvpStatus status;

    @Column(name = "responded_at", nullable = false)
    private Instant respondedAt;
}

Dependencies: Step 2.5.2 Acceptance criteria: Entities validate at startup. CRUD operations work. Unique constraint on (event_id, member_id) enforced.


Step 2.5.4 — Backend: Event repositories

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubEventRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/EventRsvpRepository.java

Key methods:

  • ClubEventRepository.findByTenantIdAndStartAtBetweenOrderByStartAtAsc(UUID, Instant, Instant) — date range query for calendar view
  • ClubEventRepository.findByTenantIdAndStartAtAfterOrderByStartAtAsc(UUID, Instant, Pageable) — upcoming events
  • EventRsvpRepository.findByEventId(UUID) — all RSVPs for an event
  • EventRsvpRepository.findByEventIdAndMemberId(UUID, UUID) — existing RSVP check
  • EventRsvpRepository.countByEventIdAndStatus(UUID, RsvpStatus) — attendee count by status

Dependencies: Step 2.5.3 Acceptance criteria: Date range query returns events within bounds. RSVP count aggregation works.


Step 2.5.5 — Backend: EventService

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/EventService.java

Methods:

Method Description
createEvent(request, creatorId) Create event + notify all members
updateEvent(id, request) Update event details
cancelEvent(id) Delete event + notify RSVPs
getEvent(id) Fetch event with RSVP counts
listEvents(from, to) Date range query for calendar
listUpcomingEvents(limit) Next N events for portal widget
rsvp(eventId, memberId, status) Create/update RSVP
getAttendees(eventId) List all RSVPs with member info
generateIcal(eventId) Generate .ics file content
expandRecurring(event, from, to) Expand recurring events into occurrences

Business rules:

  • On createEvent: send EVENT_CREATED notification to all club members (broadcast)
  • On cancelEvent: send EVENT_CANCELLED notification to all members who RSVP'd ACCEPTED/MAYBE
  • RSVP respects maxAttendees — if event is full, ACCEPTED rejected (return EVENT_FULL error); MAYBE and DECLINED always allowed
  • Recurring event expansion: generate virtual event instances between from and to dates based on recurrenceRule + recurrenceEndDate
  • iCal generation: standard RFC 5545 VCALENDAR/VEVENT format with DTSTART, DTEND, SUMMARY, DESCRIPTION, LOCATION, RRULE

iCal generation approach:

public String generateIcal(UUID eventId) {
    var event = repository.findById(eventId).orElseThrow();
    var sb = new StringBuilder();
    sb.append("BEGIN:VCALENDAR\r\n");
    sb.append("VERSION:2.0\r\n");
    sb.append("PRODID:-//CannaManage//Events//EN\r\n");
    sb.append("BEGIN:VEVENT\r\n");
    sb.append("UID:").append(event.getId()).append("@cannamanage.de\r\n");
    sb.append("DTSTART:").append(formatIcalDate(event.getStartAt())).append("\r\n");
    if (event.getEndAt() != null) {
        sb.append("DTEND:").append(formatIcalDate(event.getEndAt())).append("\r\n");
    }
    sb.append("SUMMARY:").append(escapeIcal(event.getTitle())).append("\r\n");
    if (event.getDescription() != null) {
        sb.append("DESCRIPTION:").append(escapeIcal(event.getDescription())).append("\r\n");
    }
    if (event.getLocation() != null) {
        sb.append("LOCATION:").append(escapeIcal(event.getLocation())).append("\r\n");
    }
    if (event.isRecurring() && event.getRecurrenceRule() != null) {
        sb.append("RRULE:FREQ=").append(toIcalFreq(event.getRecurrenceRule()));
        if (event.getRecurrenceEndDate() != null) {
            sb.append(";UNTIL=").append(formatIcalDate(event.getRecurrenceEndDate()));
        }
        sb.append("\r\n");
    }
    sb.append("END:VEVENT\r\n");
    sb.append("END:VCALENDAR\r\n");
    return sb.toString();
}

Dependencies: Step 2.5.4, NotificationService (Phase 1) Acceptance criteria: Full CRUD works. RSVP enforces max attendees. Recurring events expand correctly. iCal output validates against RFC 5545.


Step 2.5.6 — Backend: EventController

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/EventController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/CreateEventRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/UpdateEventRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventDetailResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpResponse.java

Endpoints:

Method Path Access Description
POST /api/v1/events MANAGE_INFO_BOARD Create event
GET /api/v1/events Any authenticated List events (query params: from, to)
GET /api/v1/events/{id} Any authenticated Event detail + attendee list
PUT /api/v1/events/{id} MANAGE_INFO_BOARD Update event
DELETE /api/v1/events/{id} MANAGE_INFO_BOARD Cancel event
POST /api/v1/events/{id}/rsvp Any member RSVP (accept/decline/maybe)
GET /api/v1/events/{id}/ical Any authenticated Download .ics file
GET /api/v1/portal/events Portal member Upcoming events for member's club

Request DTOs:

public record CreateEventRequest(
    @NotBlank @Size(max = 200) String title,
    String description,
    @NotNull EventType eventType,
    @NotNull Instant startAt,
    Instant endAt,
    @Size(max = 300) String location,
    Integer maxAttendees,
    boolean recurring,
    RecurrenceRule recurrenceRule,      // required if recurring=true
    LocalDate recurrenceEndDate         // optional, defaults to 1 year out
) {}

public record RsvpRequest(
    @NotNull RsvpStatus status  // ACCEPTED, DECLINED, MAYBE
) {}

Dependencies: Step 2.5.5 Acceptance criteria: Permission checks enforced. iCal endpoint returns text/calendar content type. RSVP returns 409 if event is full.


Step 2.5.7 — Backend: Event reminder scheduler

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/EventReminderScheduler.java

Approach:

@Slf4j
@Service
public class EventReminderScheduler {

    @Scheduled(fixedRate = 3600000)  // Every hour
    public void sendUpcomingEventReminders() {
        var now = Instant.now();
        var in24h = now.plus(24, ChronoUnit.HOURS);
        var in25h = now.plus(25, ChronoUnit.HOURS);

        // Find events starting between 24-25 hours from now (1-hour window to avoid duplicates)
        var upcomingEvents = eventRepository.findByStartAtBetween(in24h, in25h);

        for (var event : upcomingEvents) {
            // Send EVENT_REMINDER to all members who RSVP'd ACCEPTED or MAYBE
            var attendees = rsvpRepository.findByEventIdAndStatusIn(
                event.getId(), List.of(RsvpStatus.ACCEPTED, RsvpStatus.MAYBE));

            for (var rsvp : attendees) {
                notificationService.sendToUser(
                    rsvp.getMember().getUserId(),
                    "Erinnerung: " + event.getTitle(),
                    "Morgen um " + formatTime(event.getStartAt()) + " — " + event.getLocation(),
                    "/portal/events/" + event.getId(),
                    NotificationType.EVENT_REMINDER
                );
            }
            log.info("Sent {} reminders for event {}", attendees.size(), event.getId());
        }
    }
}

Dependencies: Step 2.5.5, NotificationService + DispatchService (Phase 1B) Acceptance criteria: Reminder sent ~24h before event. Only ACCEPTED/MAYBE attendees notified. Reminder uses multi-channel dispatch (in-app + push if enabled).


Step 2.5.8 — Frontend: Admin calendar page

Files to create:

  • cannamanage-frontend/src/app/(dashboard-layout)/calendar/page.tsx — Month calendar view + event list
  • cannamanage-frontend/src/app/(dashboard-layout)/calendar/new/page.tsx — Create event form
  • cannamanage-frontend/src/app/(dashboard-layout)/calendar/[eventId]/page.tsx — Event detail + attendee list
  • cannamanage-frontend/src/services/events.ts — API service
  • cannamanage-frontend/src/components/calendar/month-grid.tsx — Simple month grid component
  • cannamanage-frontend/src/components/calendar/event-card.tsx — Event card for list view
  • cannamanage-frontend/src/components/calendar/event-type-badge.tsx — Colored type badge

Approach:

  • Month grid: simple CSS grid (7 columns), date cells show dot indicators for events on that day
  • Click on day: shows events for that day in a side panel or below the grid
  • Event list: below calendar grid, shows upcoming events with type badge, date, location, attendee count
  • Create form: title, description (textarea), type (select), start date/time picker, end date/time picker, location, max attendees, recurring toggle (shows rule select + end date when active)
  • Event detail: shows all info + attendee list (name, RSVP status) + iCal download button
  • No external calendar library — build a lightweight month grid with plain CSS Grid + date-fns
  • Uses shadcn/ui: Card, Button, Select, Dialog, Badge, Calendar (date picker only)

Dependencies: Step 2.5.6 (API) Acceptance criteria: Admin can create/edit/cancel events. Calendar grid shows event dots. Attendee list visible. iCal download works.


Step 2.5.9 — Frontend: Portal events view

Files to create:

  • cannamanage-frontend/src/app/(portal-layout)/portal/events/page.tsx — Full events list page
  • cannamanage-frontend/src/app/(portal-layout)/portal/events/[eventId]/page.tsx — Event detail + RSVP
  • cannamanage-frontend/src/components/portal/upcoming-events-widget.tsx — Dashboard widget (next 3 events)

Approach:

  • Portal dashboard: widget shows next 3 upcoming events with title, date, location, RSVP button
  • Full page: list of upcoming events, shows own RSVP status per event
  • Event detail: full info + RSVP buttons (Zusage / Absage / Vielleicht), attendee count, iCal download
  • RSVP buttons use React Query useMutation with optimistic update
  • If event is full (max_attendees reached): show "Ausgebucht" badge, disable RSVP

Dependencies: Step 2.5.6 (API), Step 2.5.8 (shared components) Acceptance criteria: Portal members see upcoming events. RSVP updates immediately. Full event shows "Ausgebucht" when at capacity.


Step 2.5.10 — Frontend: Navigation update for Calendar

Files to modify:

Approach: Add "Kalender" to the Communication nav group (after "Anbau" in sidebar, as specified):

{
  title: "Communication",
  items: [
    { title: "Info Board", href: "/info-board", iconName: "Megaphone" },
    { title: "Kalender", href: "/calendar", iconName: "Calendar" },
    { title: "Forum", href: "/forum", iconName: "MessageSquare" },
    { title: "Benachrichtigungen", href: "/settings/notifications", iconName: "Bell" },
  ],
}

Dependencies: Step 2.5.8 (calendar page exists) Acceptance criteria: "Kalender" appears in sidebar navigation. Link works.


Step 2.5.11 — Integration: Auto-post events to Info Board

Files to modify:

  • EventService.java — on event creation, also create an Info Board post of type EVENT

Approach:

  • When createEvent() is called, additionally call infoBoardService.createPost() with:
    • title: event title
    • body: formatted event summary (date, time, location, description)
    • category: auto-select or create "Veranstaltungen" category
  • This makes events discoverable both on the calendar AND on the Info Board
  • Optional: can be toggled per event via a postToInfoBoard boolean in the request (default: true)

Dependencies: Step 2.5.5, InfoBoardService (Phase 2) Acceptance criteria: Creating an event also creates an info board post. Info board post links to event detail.


Phase 3: Club-Internal Forum (MVP)

Implements threaded discussions within a club with basic moderation.

Step 3.1 — Flyway migration V15: Forum tables

Files to create:

  • cannamanage-api/src/main/resources/db/migration/V15__forum.sql

SQL: (from analysis document section 2.4)

  • forum_categories — name, description, icon, sort_order, tenant_id
  • forum_topics — title, body, category_id, author_id, pinned, locked, reply_count, last_reply_at, last_reply_by, tenant_id
  • forum_replies — topic_id, body, author_id, edited_at, tenant_id
  • forum_reports — reporter_id, target_type, target_id, reason, resolved, resolved_by, resolved_at, tenant_id
  • forum_reactions — user_id, target_type, target_id, reaction, tenant_id (UNIQUE constraint)
  • Indexes on tenant+category+pinned+last_reply_at, topic+created_at, and reports unresolved

Dependencies: V12 must run first Acceptance criteria: All tables created with correct constraints and indexes.


Step 3.2 — Backend: Forum entities

Files to create:

  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumCategory.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumTopic.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReply.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReport.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReaction.java
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumTargetType.java — enum: TOPIC, REPLY
  • cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumReactionType.java — enum: THUMBSUP, LEAF, FIRE, HEART

Approach:

  • All extend AbstractTenantEntity
  • ForumTopic has denormalized replyCount, lastReplyAt, lastReplyBy fields
  • ForumTopic.updatedAt for edit tracking
  • ForumReply.editedAt for edit indicator in UI
  • ForumReaction has a @UniqueConstraint on (user_id, target_type, target_id, reaction)

Dependencies: Step 3.1 Acceptance criteria: Hibernate validates all mappings at startup.


Step 3.3 — Backend: Forum repositories

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ForumCategoryRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ForumTopicRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ForumReplyRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ForumReportRepository.java
  • cannamanage-service/src/main/java/de/cannamanage/service/repository/ForumReactionRepository.java

Key methods:

  • ForumTopicRepository.findByCategoryIdAndTenantIdOrderByPinnedDescLastReplyAtDesc(UUID, UUID, Pageable)
  • ForumReplyRepository.findByTopicIdOrderByCreatedAtAsc(UUID, Pageable)
  • ForumReportRepository.findByResolvedFalseOrderByCreatedAtAsc(Pageable) — moderation queue
  • ForumReactionRepository.countByTargetTypeAndTargetIdGroupByReaction(...) — reaction counts
  • ForumReactionRepository.findByUserIdAndTargetTypeAndTargetId(...) — user's existing reactions

Dependencies: Step 3.2 Acceptance criteria: Queries return expected results with correct ordering/pagination.


Step 3.4 — Backend: ForumService

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/ForumService.java

Methods:

Method Description
createCategory(name, description, icon, sortOrder) Admin creates category
listCategories() List categories with topic counts
createTopic(title, body, categoryId, authorId) Member creates topic
getTopic(id) Fetch topic + paginated replies
editTopic(id, title, body, userId) Edit within 30-min window
pinTopic(id) / lockTopic(id) Moderator actions
deleteTopic(id) Moderator soft-delete (body = "[Removed]")
createReply(topicId, body, authorId) Add reply + update denormalized fields
editReply(id, body, userId) Edit within 30-min window
deleteReply(id) Moderator soft-delete
reportContent(reporterId, targetType, targetId, reason) File report
listReports(Pageable) Moderation queue
resolveReport(id, resolvedBy) Mark report resolved
addReaction(userId, targetType, targetId, reaction) Toggle reaction
removeReaction(userId, targetType, targetId, reaction) Remove reaction

Business rules:

  • Edit window: 60 minutes from creation (createdAt.plus(60, MINUTES).isAfter(Instant.now())). After edit, editedAt is set and UI shows "(bearbeitet)" indicator.
  • On createReply: increment topic.replyCount, set topic.lastReplyAt and topic.lastReplyBymust be in same @Transactional scope as the reply INSERT to prevent inconsistency.
  • On createReply: send FORUM_REPLY notification to topic author (if different from replier)
  • On locked topic: reject new replies with 403
  • Delete = replace body with "[Beitrag entfernt]", keep metadata for audit trail
  • Moderation notification (Vereinsrecht requirement): On deleteTopic(), deleteReply(), and lockTopic(): send an IN_APP notification to the content author explaining that their content was moderated. Notification includes: action taken (deleted/locked), reason (from moderator), and a link to club rules. This satisfies Vereinsrecht fair-process requirements for administrative actions.
  • Reporter identity protection: reporter_id in forum_reports is NEVER exposed to the reported content's author or to staff with only MODERATE_FORUM permission. Only users with ADMIN role can see reporter identity. The ReportResponse DTO has two variants: ReportResponseAdmin (includes reporterName) and ReportResponse (excludes it). Controller checks role before selecting variant.

Dependencies: Step 3.3, NotificationService (Phase 1) Acceptance criteria: Full topic lifecycle works. Edit after 60 min rejected. Locked topic blocks replies. Notifications sent on reply. Content author receives notification when their post is moderated (with reason). Reporter identity hidden from non-ADMIN users.


Step 3.5 — Backend: ForumController

Files to create:

  • cannamanage-api/src/main/java/de/cannamanage/api/controller/ForumController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/CreateTopicRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/TopicResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/TopicDetailResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReplyResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/CreateReplyRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReportRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReportResponse.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReactionRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ForumCategoryResponse.java

Endpoints:

Method Path Access Description
GET /api/v1/forum/categories Any member List categories
POST /api/v1/forum/categories ADMIN Create category
GET /api/v1/forum/topics Any member List topics (paginated, by category)
POST /api/v1/forum/topics Any member Create topic
GET /api/v1/forum/topics/{id} Any member Topic + replies
PUT /api/v1/forum/topics/{id} Author (30-min) Edit topic
PUT /api/v1/forum/topics/{id}/pin MODERATE_FORUM Pin/unpin
PUT /api/v1/forum/topics/{id}/lock MODERATE_FORUM Lock/unlock
DELETE /api/v1/forum/topics/{id} MODERATE_FORUM Delete topic
POST /api/v1/forum/topics/{id}/replies Any member Post reply
PUT /api/v1/forum/replies/{id} Author (30-min) Edit reply
DELETE /api/v1/forum/replies/{id} MODERATE_FORUM Delete reply
POST /api/v1/forum/reports Any member Report content
GET /api/v1/forum/reports MODERATE_FORUM Moderation queue
PUT /api/v1/forum/reports/{id}/resolve MODERATE_FORUM Resolve report
POST /api/v1/forum/reactions Any member Add reaction
DELETE /api/v1/forum/reactions/{id} Owner Remove reaction

Dependencies: Step 3.4 Acceptance criteria: All endpoints respect permission boundaries. Members can't moderate. Moderators can't be blocked from actions.


Step 3.6 — Frontend: Forum pages (admin + portal)

Files to create:

  • cannamanage-frontend/src/app/(dashboard-layout)/forum/page.tsx — Forum main (category list → topic list)
  • cannamanage-frontend/src/app/(dashboard-layout)/forum/[categoryId]/page.tsx — Topic list in category
  • cannamanage-frontend/src/app/(dashboard-layout)/forum/topics/[topicId]/page.tsx — Topic detail + replies
  • cannamanage-frontend/src/app/(dashboard-layout)/forum/topics/new/page.tsx — New topic form
  • cannamanage-frontend/src/app/(dashboard-layout)/forum/moderation/page.tsx — Report queue (staff only)
  • cannamanage-frontend/src/app/(portal-layout)/portal/forum/page.tsx — Portal forum entry
  • cannamanage-frontend/src/app/(portal-layout)/portal/forum/[categoryId]/page.tsx — Portal category
  • cannamanage-frontend/src/app/(portal-layout)/portal/forum/topics/[topicId]/page.tsx — Portal topic detail
  • cannamanage-frontend/src/services/forum.ts — API service

Shared components:

  • cannamanage-frontend/src/components/forum/topic-card.tsx — Topic list item
  • cannamanage-frontend/src/components/forum/reply-card.tsx — Single reply
  • cannamanage-frontend/src/components/forum/reaction-bar.tsx — Emoji reaction buttons with counts
  • cannamanage-frontend/src/components/forum/moderation-actions.tsx — Lock/delete/pin dropdown (conditionally shown to moderators)
  • cannamanage-frontend/src/components/forum/report-dialog.tsx — Report content modal

Approach:

  • Reuse MarkdownEditor from Phase 2
  • Topic list: title, author display name, reply count, last activity timestamp
  • Topic detail: original post + chronological replies, reply editor at bottom
  • Reaction bar on each post/reply
  • Moderation actions in dropdown (only visible to users with MODERATE_FORUM permission)
  • Report dialog: textarea for reason, confirmation

Dependencies: Step 3.5 (API), MarkdownEditor from Phase 2 Acceptance criteria: Full forum flow works: browse categories → read topics → create topic → reply → react. Moderators see moderation controls. Report queue shows unresolved reports.


Phase 4: Integration & Polish

Connects all three features with real-time updates, audit logging, and navigation updates.

Step 4.1 — WebSocket: New topic/reply events for forum

Files to modify:

Approach:

  • Register new STOMP destinations:
    • /topic/club/{tenantId}/forum — new topic or reply events
    • /topic/club/{tenantId}/info-board — new info board post events
  • On ForumService.createReply(): push event to forum topic subscription
  • On InfoBoardService.createPost(): push event to info-board subscription
  • Frontend hooks: useForumSubscription(topicId), useInfoBoardSubscription()

Files to create:

  • cannamanage-frontend/src/hooks/use-forum-subscription.ts
  • cannamanage-frontend/src/hooks/use-info-board-subscription.ts

Dependencies: Phase 2 + Phase 3 complete Acceptance criteria: New reply appears in real-time for other users viewing same topic. New info board post appears without refresh.


Step 4.2 — Audit logging for all new operations

Files to modify:

  • InfoBoardService.java — add AuditService.log(...) calls
  • ForumService.java — add AuditService.log(...) calls

Approach: Log the following events using existing AuditService:

  • INFO_BOARD_POST_CREATED — on post creation
  • INFO_BOARD_POST_EDITED — on post update
  • INFO_BOARD_POST_PINNED — on pin toggle
  • INFO_BOARD_POST_ARCHIVED — on archive
  • FORUM_TOPIC_CREATED — on topic creation
  • FORUM_TOPIC_LOCKED — on lock
  • FORUM_TOPIC_DELETED — on delete
  • FORUM_REPLY_DELETED — on reply delete
  • FORUM_REPORT_RESOLVED — on report resolution

Dependencies: Step 1.3 (enum values exist) Acceptance criteria: All operations appear in audit log. Audit page shows new event types correctly.


Step 4.3 — Portal navigation update

Files to modify/create:

  • Portal navigation data (wherever portal nav is defined — likely in portal layout)
  • Portal dashboard page (add widgets)

Approach:

  • Add "Ankündigungen" and "Forum" links to portal nav
  • Portal dashboard: add "Neueste Ankündigungen" widget (latest 3 info board posts) and "Forum-Aktivität" widget (latest 3 active topics)

Dependencies: Phase 2 + Phase 3 frontend pages Acceptance criteria: Portal members can navigate to announcements and forum from both nav and dashboard widgets.


Step 4.4 — Plan tier enforcement

Files to modify:

  • InfoBoardService.java — check tier limits
  • ForumService.java — check tier access

Tier rules:

Feature Starter Pro Enterprise
Info Board 3 categories max, no attachments Unlimited categories, 10 MB attachments Full + scheduled posts
Forum Not available 5 categories max Unlimited

Approach:

  • Inject SubscriptionRepository to check current club tier
  • On category creation: count existing, reject if over limit
  • On attachment upload (Starter): reject with 403 + upgrade message
  • On forum access (Starter): reject with 403 + upgrade message

Dependencies: Existing Subscription entity and tier checks Acceptance criteria: Starter club can't access forum. Starter club limited to 3 info board categories. Pro club limited to 5 forum categories.


Step 4.5 — Email delivery via IONOS SMTP

Decision (2026-06-13): All email is sent through the plate-software.de IONOS SMTP relay. No third-party email services (SendGrid, Mailgun, etc.). Default sender: noreply@cannamanage.plate-software.de.

Files to modify:

Files to create:

  • cannamanage-api/src/main/resources/templates/notification-email.txt — notification email template
  • cannamanage-api/src/main/resources/db/migration/V14__member_email_notifications.sql — opt-in column

Spring Boot mail configuration (application-production.properties):

# 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

# Default sender address
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}

IONOS setup steps (one-time):

  1. Create mailbox noreply@cannamanage.plate-software.de in IONOS admin panel
  2. Set SPF record for cannamanage.plate-software.de: v=spf1 include:_spf.perfora.net include:_spf.kundenserver.de ~all
  3. Enable DKIM signing in IONOS DNS settings
  4. Store SMTP credentials as Docker secrets in production

EmailService integration:

  • On broadcast notification send: if member has EMAIL channel enabled in preferences, dispatch email
  • Email template: plain text with title, message body, action link, unsubscribe footer
  • Unsubscribe link: one-click POST /api/v1/notifications/preferences to disable email channel
  • Rate limiting: max 50 emails per broadcast batch (IONOS limits). Implementation uses Resilience4j RateLimiter configured at 50 permits per 60-second window. The EmailService uses a dedicated TaskExecutor with a fixed thread pool of 2 threads. Broadcast sends are chunked into batches of 50, with a 1-second Thread.sleep() between batches. Configuration: resilience4j.ratelimiter.instances.ionos-smtp.limitForPeriod=50 / limitRefreshPeriod=60s / timeoutDuration=30s. This prevents IONOS throttling (HTTP 429 / SMTP 451) during large broadcasts.

Flyway migration V16:

-- No longer needed as separate column — email opt-in is handled by notification_preferences table (channel=EMAIL, enabled=true/false)
-- This migration adds an index for faster email dispatch queries
CREATE INDEX idx_notification_preferences_email_enabled
    ON notification_preferences(tenant_id, channel, enabled)
    WHERE channel = 'EMAIL' AND enabled = true;

Dependencies: Phase 1 broadcast, Phase 1B notification preferences (channel-based opt-in already covers email) Acceptance criteria: Admin sends broadcast → members with EMAIL channel enabled receive email via IONOS SMTP. Email arrives with correct FROM address. Members without email opt-in receive nothing. IONOS rate limits respected.


Step 4.6 — Enterprise tier: Custom FROM address (DNS-verified)

Decision (2026-06-13): Enterprise tier clubs can configure a custom FROM address (e.g., vorstand@gruener-daumen.de) verified via DNS TXT record. Starter/Pro tiers always send from noreply@cannamanage.plate-software.de.

Files to create:

  • cannamanage-service/src/main/java/de/cannamanage/service/CustomMailDomainService.java
  • cannamanage-api/src/main/java/de/cannamanage/api/controller/MailSettingsController.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/mail/CustomMailDomainRequest.java
  • cannamanage-api/src/main/java/de/cannamanage/api/dto/mail/MailDomainStatusResponse.java
  • cannamanage-api/src/main/resources/db/migration/V17__custom_mail_domains.sql

Flyway migration V17:

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()
);

Verification flow:

  1. Enterprise admin enters desired FROM address (e.g., info@gruener-daumen.de)
  2. System extracts domain, generates unique verification token
  3. Admin is instructed to add DNS TXT record: cannamanage-verify=<token>
  4. Admin clicks "Verify" → system does DNS TXT lookup via InetAddress / javax.naming.directory
  5. If record found and matches → mark as verified
  6. All outbound emails for this club now use the custom FROM address (via IONOS SMTP with envelope sender rewrite)

API endpoints:

Method Path Access Description
POST /api/v1/settings/mail/custom-domain ADMIN + Enterprise Set custom FROM
GET /api/v1/settings/mail/custom-domain ADMIN + Enterprise Get status
POST /api/v1/settings/mail/custom-domain/verify ADMIN + Enterprise Trigger DNS check
DELETE /api/v1/settings/mail/custom-domain ADMIN + Enterprise Remove custom FROM

Security requirement: The custom FROM address requires the club to add both:

  • SPF: include _spf.perfora.net (IONOS) in their domain's SPF record
  • TXT: cannamanage-verify=<token> for ownership proof

This prevents spoofing — emails are only sent "from" a domain the club provably controls.

Dependencies: Step 4.5 (IONOS SMTP working), Enterprise tier check Acceptance criteria: Enterprise club sets custom FROM → verifies DNS → emails sent with custom address. Non-Enterprise clubs get 403. Unverified domains blocked from sending.


Security Policy: No Custom SMTP Servers

Decision (2026-06-13) — SECURITY POLICY:

Clubs are NOT allowed to configure their own SMTP servers. All email is routed through the platform's IONOS SMTP relay. This is a deliberate security decision:

Rationale:

  • Prevents abuse (spam, phishing) via club-controlled SMTP servers
  • Ensures all outbound email has proper SPF/DKIM alignment under our control
  • Simplifies deliverability monitoring (single sending infrastructure)
  • Avoids liability if a club's SMTP server is compromised
  • Guarantees audit trail — all email passes through platform infrastructure

What IS allowed:

  • Enterprise tier: custom FROM address (verified via DNS TXT record)
  • All tiers: platform sends from noreply@cannamanage.plate-software.de

What is NOT allowed:

  • Custom SMTP host/port/credentials configuration per club
  • Direct SMTP relay passthrough
  • Third-party email service integration per club

Phase 5: Testing & QA

Step 5.1 — Unit tests for all new services

Files to create:

  • cannamanage-service/src/test/java/de/cannamanage/service/InfoBoardServiceTest.java
  • cannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.java
  • cannamanage-service/src/test/java/de/cannamanage/service/NotificationServiceBroadcastTest.java
  • cannamanage-service/src/test/java/de/cannamanage/service/FileStorageServiceTest.java

See test plan document for detailed test cases.


Step 5.2 — Integration tests for API endpoints

Files to create:

  • cannamanage-api/src/test/java/de/cannamanage/api/controller/InfoBoardControllerIntegrationTest.java
  • cannamanage-api/src/test/java/de/cannamanage/api/controller/ForumControllerIntegrationTest.java
  • cannamanage-api/src/test/java/de/cannamanage/api/controller/NotificationComposeControllerIntegrationTest.java

Approach: Spring Boot @WebMvcTest or @SpringBootTest with TestContainers PostgreSQL. Test permission checks, request validation, response structure.


Step 5.3 — Playwright E2E tests

Files to create:

  • cannamanage-frontend/e2e/info-board.spec.ts
  • cannamanage-frontend/e2e/forum.spec.ts
  • cannamanage-frontend/e2e/notification-compose.spec.ts

Scenarios:

  • Info Board: create post → verify appears on portal → pin → verify pinned first → archive
  • Forum: create topic → reply → verify reply count → report → moderator resolves
  • Notifications: compose broadcast → verify bell badge updates → mark as read

Step 5.4 — Tenant isolation verification

Dedicated test: Create data in Club A, verify Club B cannot see it via API. Cover:

  • Info board posts
  • Forum topics/replies
  • Notifications
  • Forum reports

Step 5.5 — Permission/authorization tests

Cover:

  • Staff without MANAGE_INFO_BOARD can't create/edit/archive posts
  • Staff without MODERATE_FORUM can't lock/delete/pin topics or resolve reports
  • Staff without SEND_NOTIFICATIONS can't compose notifications
  • Portal members can't access admin-only endpoints
  • Edit window (30 min) enforced — edit after window returns 403

File Summary

New files to create (backend)

File Phase
db/migration/V11__notification_sends.sql 1
db/migration/V11b__push_notification_infra.sql 1B
db/migration/V12__info_board.sql 2
db/migration/V13__forum.sql 3
db/migration/V14__member_email_notifications.sql 4
domain/entity/NotificationSend.java 1
domain/entity/NotificationSendRecipient.java 1
domain/entity/DeviceToken.java 1B
domain/entity/NotificationPreference.java 1B
domain/enums/DevicePlatform.java 1B
domain/enums/NotificationChannel.java 1B
domain/entity/InfoBoardCategory.java 2
domain/entity/InfoBoardPost.java 2
domain/entity/InfoBoardAttachment.java 2
domain/entity/ForumCategory.java 3
domain/entity/ForumTopic.java 3
domain/entity/ForumReply.java 3
domain/entity/ForumReport.java 3
domain/entity/ForumReaction.java 3
domain/enums/InfoBoardCategoryType.java 2
domain/enums/ForumTargetType.java 3
domain/enums/ForumReactionType.java 3
service/repository/NotificationSendRepository.java 1
service/repository/DeviceTokenRepository.java 1B
service/repository/NotificationPreferenceRepository.java 1B
service/repository/InfoBoardCategoryRepository.java 2
service/repository/InfoBoardPostRepository.java 2
service/repository/InfoBoardAttachmentRepository.java 2
service/repository/ForumCategoryRepository.java 3
service/repository/ForumTopicRepository.java 3
service/repository/ForumReplyRepository.java 3
service/repository/ForumReportRepository.java 3
service/repository/ForumReactionRepository.java 3
service/InfoBoardService.java 2
service/ForumService.java 3
service/FileStorageService.java 2
service/DeviceRegistrationService.java 1B
service/NotificationPreferenceService.java 1B
service/NotificationDispatchService.java 1B
service/push/WebPushSender.java 1B
service/push/FcmPushSender.java 1B
service/push/PushPayload.java 1B
api/controller/NotificationComposeController.java 1
api/controller/DeviceRegistrationController.java 1B
api/controller/NotificationPreferenceController.java 1B
api/controller/InfoBoardController.java 2
api/controller/ForumController.java 3
DTOs (15+ files across phases) 1-4

New files to create (frontend)

File Phase
services/notification-compose.ts 1
lib/push-subscription.ts 1B
components/push-permission-prompt.tsx 1B
services/info-board.ts 2
services/forum.ts 3
app/(dashboard)/settings/notifications/page.tsx 1
app/(dashboard)/settings/notifications/compose/page.tsx 1
app/(dashboard)/info-board/page.tsx 2
app/(dashboard)/info-board/new/page.tsx 2
app/(dashboard)/forum/page.tsx 3
app/(dashboard)/forum/[categoryId]/page.tsx 3
app/(dashboard)/forum/topics/[topicId]/page.tsx 3
app/(dashboard)/forum/topics/new/page.tsx 3
app/(dashboard)/forum/moderation/page.tsx 3
app/(portal)/portal/announcements/page.tsx 2
app/(portal)/portal/forum/page.tsx 3
app/(portal)/portal/forum/[categoryId]/page.tsx 3
app/(portal)/portal/forum/topics/[topicId]/page.tsx 3
components/markdown-editor.tsx 2
components/info-board/post-card.tsx 2
components/info-board/category-badge.tsx 2
components/portal/announcements-widget.tsx 2
components/forum/topic-card.tsx 3
components/forum/reply-card.tsx 3
components/forum/reaction-bar.tsx 3
components/forum/moderation-actions.tsx 3
components/forum/report-dialog.tsx 3
hooks/use-forum-subscription.ts 4
hooks/use-info-board-subscription.ts 4

Files to modify

File Change
NotificationType.java Add 4 new enum values
StaffPermission.java Add 3 new enum values
AuditEventType.java Add 9 new enum values
NotificationService.java Add broadcast/targeted methods + dispatch integration
WebSocketConfig.java Register new STOMP destinations
navigations.ts Add Communication section
EmailService.java Add notification email template support
sw.js Add push event handler + notification click handler
Portal navigation/layout Add announcements + forum links
Portal dashboard page Add widgets
pom.xml Add firebase-admin + web-push-java dependencies
application.properties Add VAPID + FCM configuration properties