706a6e257b
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
1948 lines
84 KiB
Markdown
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 |
|