# 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: ```mermaid 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:** - [`NotificationType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java) **Approach:** Add new enum constants for Sprint 7 features. ```java 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:** - [`StaffPermission.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java) **Approach:** Add three new permissions for Sprint 7. ```java 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:** - [`AuditEventType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java) **Approach:** Add audit events for all Sprint 7 features. ```java // 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:** ```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:** - [`NotificationService.java`](cannamanage-service/src/main/java/de/cannamanage/service/NotificationService.java) **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 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:** ```java public record ComposeNotificationRequest( @NotBlank String title, @NotBlank String message, String link, @NotNull TargetType targetType, // ALL or SELECTED List 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:** - [`navigations.ts`](cannamanage-frontend/src/data/navigations.ts) **Approach:** Add navigation items for Sprint 7 features: ```typescript { 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. ```mermaid 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:** ```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:** ```java // 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:** ```java 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:** ```java @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:** ```java @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 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):** ```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):** ```bash 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`](cannamanage-frontend/public/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`):** ```javascript // 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:** ```typescript // push-subscription.ts export async function subscribeToPush(vapidPublicKey: string): Promise { 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):** ```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:** ```java @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:** ```xml com.google.firebase firebase-admin 9.3.0 ``` **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. ```mermaid 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:** - [`NotificationType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java) - [`AuditEventType.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java) - [`StaffPermission.java`](cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java) **Approach:** Add new enum constants: ```java // 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:** ```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:** ```java // 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 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:** ```java 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:** ```java 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:** ```java @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:** - [`navigations.ts`](cannamanage-frontend/src/data/navigations.ts) **Approach:** Add "Kalender" to the Communication nav group (after "Anbau" in sidebar, as specified): ```typescript { 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.lastReplyBy` — **must 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:** - [`WebSocketConfig.java`](cannamanage-api/src/main/java/de/cannamanage/api/config/WebSocketConfig.java) - `ForumService.java` (add WebSocket push on topic/reply creation) **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:** - [`EmailService.java`](cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java) — wire up for notification emails - [`application-production.properties`](cannamanage-api/src/main/resources/application-production.properties) — SMTP config **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):** ```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:** ```sql -- 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:** ```sql 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=` 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=` 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 |