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

1948 lines
84 KiB
Markdown

# 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<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:**
```java
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:**
- [`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<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):**
```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<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):**
```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
<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.
```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<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:**
```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=<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 |