# 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 |
