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
84 KiB
Sprint 7 Implementation Plan — Communication & Community
Date: 2026-06-13
Author: Patrick Plate / Lumen (Architect)
Status: Draft v2
Based on: cannamanage-sprint7-analysis.md
Sprint Goal: Admin Notifications + Info Board + Club Event Calendar + Club-Internal Forum (MVP)
Overview
This plan implements four communication features in six phases:
graph TD
P1[Phase 1: Admin Notifications] --> P2[Phase 2: Info Board]
P2 --> P2_5[Phase 2.5: Club Event Calendar]
P1 --> P3[Phase 3: Forum MVP]
P2_5 --> P4[Phase 4: Integration and Polish]
P3 --> P4
P4 --> P5[Phase 5: Testing and QA]
subgraph Existing
NS[NotificationService]
WS[WebSocket/STOMP]
SP[StaffPermission enum]
AU[AuditService]
end
NS --> P1
WS --> P1
SP --> P1
SP --> P2
SP --> P2_5
SP --> P3
AU --> P4
Phase 1: Admin Notifications Enhancement
Extends the existing notification infrastructure with admin-composed messages, broadcast/targeted delivery, and a compose UI.
Step 1.1 — Extend NotificationType enum
Files to modify:
Approach: Add new enum constants for Sprint 7 features.
public enum NotificationType {
QUOTA_WARNING,
BATCH_RECALLED,
DISTRIBUTION_RECORDED,
SUBSCRIPTION_EXPIRING,
// Sprint 7:
ADMIN_MESSAGE,
INFO_BOARD_POST,
FORUM_REPLY,
FORUM_MENTION
}
Dependencies: None (leaf change) Acceptance criteria: Enum compiles, no existing functionality breaks.
Step 1.2 — Extend StaffPermission enum
Files to modify:
Approach: Add three new permissions for Sprint 7.
SEND_NOTIFICATIONS, // Compose + send admin notifications
MANAGE_INFO_BOARD, // Create/edit/pin/archive info board posts
MODERATE_FORUM // Delete posts, lock topics, resolve reports
Dependencies: None Acceptance criteria: Existing staff permission checks unaffected. New values available for assignment.
Step 1.3 — Extend AuditEventType enum
Files to modify:
Approach: Add audit events for all Sprint 7 features.
// Notification events
NOTIFICATION_BROADCAST_SENT,
// Info Board events
INFO_BOARD_POST_CREATED,
INFO_BOARD_POST_EDITED,
INFO_BOARD_POST_PINNED,
INFO_BOARD_POST_ARCHIVED,
// Forum events
FORUM_TOPIC_CREATED,
FORUM_TOPIC_LOCKED,
FORUM_TOPIC_DELETED,
FORUM_REPLY_DELETED,
FORUM_REPORT_RESOLVED
Dependencies: None Acceptance criteria: Compiles, audit service can log all new event types.
Step 1.4 — Flyway migration V11: notification_sends tables
Files to create:
cannamanage-api/src/main/resources/db/migration/V11__notification_sends.sql
SQL:
CREATE TABLE notification_sends (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
link VARCHAR(500),
author_id UUID NOT NULL,
target_type VARCHAR(20) NOT NULL,
target_count INTEGER NOT NULL,
read_count INTEGER NOT NULL DEFAULT 0,
sent_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE notification_send_recipients (
send_id UUID NOT NULL REFERENCES notification_sends(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
notification_id UUID REFERENCES notifications(id),
PRIMARY KEY (send_id, user_id)
);
CREATE INDEX idx_notification_sends_tenant ON notification_sends(tenant_id, sent_at DESC);
Dependencies: Existing notifications table must exist (V-series migration ordering)
Acceptance criteria: Migration runs clean on dev PostgreSQL. Tables created with correct foreign keys.
Step 1.5 — Backend: NotificationSend and NotificationSendRecipient entities
Files to create:
cannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationSend.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationSendRecipient.java
Approach:
NotificationSendextendsAbstractTenantEntity, mapsnotification_sendstable- Fields:
title,message,link,authorId,targetType(enum: ALL, SELECTED),targetCount,readCount,sentAt NotificationSendRecipientis an@IdClassentity 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
readCountbased on associated notifications' read status
Dependencies: Step 1.5 Acceptance criteria: Repository methods return correct data in tests.
Step 1.7 — Backend: Extend NotificationService with broadcast
Files to modify:
Approach: Add methods:
sendBroadcast(String title, String message, String link, UUID authorId)— creates notification for all club memberssendToSelected(String title, String message, String link, UUID authorId, List<UUID> recipientIds)— targeted send
Implementation:
- Query all active members of the current tenant (or the selected subset)
- Batch-INSERT notifications using
JdbcTemplate.batchUpdate()for performance (500+ members) - Create
NotificationSendrecord with metadata - Create
NotificationSendRecipientjoin records - 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. - Return the
NotificationSendrecord
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.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/ComposeNotificationRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/NotificationSendResponse.java
Endpoints:
| Method | Path | Permission | Description |
|---|---|---|---|
| POST | /api/v1/notifications/compose |
SEND_NOTIFICATIONS |
Compose + send |
| GET | /api/v1/notifications/sends |
SEND_NOTIFICATIONS |
List sent (paginated) |
| GET | /api/v1/notifications/sends/{id} |
SEND_NOTIFICATIONS |
Details + per-recipient read status |
Request DTO:
public record ComposeNotificationRequest(
@NotBlank String title,
@NotBlank String message,
String link,
@NotNull TargetType targetType, // ALL or SELECTED
List<UUID> recipientIds // required if SELECTED
) {}
Dependencies: Step 1.7, StaffPermissionChecker
Acceptance criteria: Permission check works. 401 for unauthorized staff. 200 on successful send.
Step 1.9 — Frontend: Notification compose page
Files to create:
cannamanage-frontend/src/app/(dashboard-layout)/settings/notifications/page.tsx— Notification history/compose parentcannamanage-frontend/src/app/(dashboard-layout)/settings/notifications/compose/page.tsx— Compose formcannamanage-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
useMutationfor send action with optimistic feedback
Dependencies: Step 1.8 (API must exist), existing members service for member list
Acceptance criteria: Admin can compose and send notification. History shows sent items with read stats.
Step 1.10 — Frontend: Update navigation
Files to modify:
Approach: Add navigation items for Sprint 7 features:
{
title: "Communication",
items: [
{ title: "Info Board", href: "/info-board", iconName: "Megaphone" },
{ title: "Forum", href: "/forum", iconName: "MessageSquare" },
{ title: "Benachrichtigungen", href: "/settings/notifications", iconName: "Bell" },
],
}
Dependencies: None (can be added early, pages will 404 until built) Acceptance criteria: Navigation renders correctly. Links work once pages exist.
Phase 1B: Multi-Channel Push Notification Architecture
Extends the notification system from Phase 1 with Web Push, Mobile Push (FCM/APNs), device registration, and per-user channel preferences. Designed to support a member mobile app in Sprint 8 while enabling Web Push immediately with the existing PWA service worker.
graph TD
NS[NotificationService.sendBroadcast] --> FO[NotificationDispatchService]
FO --> CH_INAPP[In-App Channel - WebSocket/STOMP]
FO --> CH_EMAIL[Email Channel - existing EmailService]
FO --> CH_WEBPUSH[Web Push Channel - VAPID]
FO --> CH_MOBILE[Mobile Push Channel - FCM]
subgraph Device Registry
DR[DeviceToken entity]
DR --> WEB[platform=WEB]
DR --> IOS[platform=IOS]
DR --> ANDROID[platform=ANDROID]
end
subgraph User Preferences
NP[NotificationPreference entity]
NP --> IN_APP[channel=IN_APP - always on]
NP --> EMAIL_PREF[channel=EMAIL - opt-in]
NP --> WEB_PUSH_PREF[channel=WEB_PUSH - opt-in]
NP --> MOBILE_PUSH_PREF[channel=MOBILE_PUSH - opt-in]
end
CH_WEBPUSH --> DR
CH_MOBILE --> DR
FO --> NP
Architectural Decisions
| Decision | Rationale | Alternatives Considered |
|---|---|---|
Web Push via VAPID keys + existing sw.js |
PWA service worker already registered; Web Push works immediately without native app | Polling (too slow), Server-Sent Events (no offline support) |
| FCM for cross-platform mobile push | Single API covers both Android (native) and iOS (via APNs proxy). Industry standard. | Direct APNs + custom Android (more complexity, two integrations) |
| Device tokens stored per-user, multiple allowed | Users may have phone + tablet + browser; each needs its own push subscription | Single token per user (would lose multi-device) |
| Channel preferences per-user, per-channel | Users control what they receive where. GDPR-friendly. | Global opt-in/out only (too coarse) |
| Dispatch service is channel-agnostic | Adding SMS/WhatsApp later = just a new adapter. No core changes. | Hardcoded channels in NotificationService (fragile) |
| IN_APP channel always enabled, cannot be disabled | Core functionality; users expect in-app notifications | All channels optional (could miss critical alerts) |
Step 1.11 — Flyway migration V12: Device tokens and notification preferences
Files to create:
cannamanage-api/src/main/resources/db/migration/V12__push_notification_infra.sql
SQL:
-- Device token registry (Web Push subscriptions + mobile push tokens)
CREATE TABLE device_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(20) NOT NULL, -- WEB, IOS, ANDROID
token TEXT NOT NULL, -- Push subscription JSON (Web) or FCM token (mobile)
device_name VARCHAR(100), -- e.g. "Chrome on MacBook", "iPhone 15"
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(user_id, token)
);
CREATE INDEX idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX idx_device_tokens_platform ON device_tokens(platform, tenant_id);
-- Per-user notification channel preferences
CREATE TABLE notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL, -- IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(user_id, channel)
);
CREATE INDEX idx_notification_preferences_user ON notification_preferences(user_id);
Dependencies: V11 (notification_sends) must run first. Note: Sequential migration numbering — V11 → V12 → V13 → V14 → V15 → V16 → V17. Acceptance criteria: Migration succeeds. Tables created with correct unique constraints.
Step 1.12 — Backend: Device token and preference entities
Files to create:
cannamanage-domain/src/main/java/de/cannamanage/domain/entity/DeviceToken.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/NotificationPreference.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/DevicePlatform.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationChannel.java
Approach:
// DevicePlatform enum
public enum DevicePlatform { WEB, IOS, ANDROID }
// NotificationChannel enum
public enum NotificationChannel { IN_APP, EMAIL, WEB_PUSH, MOBILE_PUSH }
// DeviceToken entity
@Entity @Table(name = "device_tokens")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class DeviceToken extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DevicePlatform platform;
@Column(nullable = false, columnDefinition = "TEXT")
private String token; // Web Push: JSON subscription object; Mobile: FCM registration token
private String deviceName;
private Instant lastUsedAt;
}
// NotificationPreference entity
@Entity @Table(name = "notification_preferences")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class NotificationPreference extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private NotificationChannel channel;
@Column(nullable = false)
private boolean enabled;
}
Dependencies: Step 1.11 Acceptance criteria: Entities validate at startup. CRUD operations work. Unique constraint enforced.
Step 1.13 — Backend: Device registration API
Files to create:
cannamanage-api/src/main/java/de/cannamanage/api/controller/DeviceRegistrationController.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/RegisterDeviceRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/DeviceTokenResponse.javacannamanage-service/src/main/java/de/cannamanage/service/repository/DeviceTokenRepository.javacannamanage-service/src/main/java/de/cannamanage/service/DeviceRegistrationService.java
Endpoints:
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /api/v1/notifications/devices |
Any authenticated | Register device token |
| GET | /api/v1/notifications/devices |
Any authenticated | List user's registered devices |
| DELETE | /api/v1/notifications/devices/{id} |
Owner only | Unregister device |
Request DTO:
public record RegisterDeviceRequest(
@NotNull DevicePlatform platform,
@NotBlank String token, // Web Push subscription JSON or FCM token
String deviceName // optional friendly name
) {}
Service logic:
- On register: upsert by (user_id, token) — if token already exists, update
lastUsedAt - On unregister: delete token record
- Auto-cleanup: scheduled job removes tokens not used in 90 days (stale subscriptions)
- Limit: max 10 devices per user (prevent abuse)
Dependencies: Step 1.12 Acceptance criteria: Portal member can register a Web Push subscription. Same subscription re-registered updates timestamp. Unregister removes token.
Step 1.14 — Backend: Notification preferences API
Files to create:
cannamanage-api/src/main/java/de/cannamanage/api/controller/NotificationPreferenceController.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/NotificationPreferenceResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/notification/UpdatePreferenceRequest.javacannamanage-service/src/main/java/de/cannamanage/service/repository/NotificationPreferenceRepository.javacannamanage-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
consentstable with:type=NOTIFICATION_PUSHortype=NOTIFICATION_EMAIL,granted_at=Instant.now(),scope="push notifications via [channel]",method="explicit opt-in via preferences UI". When disabled, the consent is revoked (setrevoked_at). This creates the audit trail required by GDPR Art. 7(1) — browser'sNotification.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.javacannamanage-service/src/main/java/de/cannamanage/service/push/WebPushSender.javacannamanage-service/src/main/java/de/cannamanage/service/push/FcmPushSender.javacannamanage-service/src/main/java/de/cannamanage/service/push/PushPayload.java
Approach:
@Slf4j
@Service
public class NotificationDispatchService {
// Called after NotificationService creates the in-app notification
public void dispatch(Notification notification, User user) {
var prefs = preferenceService.getPreferences(user.getId());
// IN_APP: already handled by NotificationService (WebSocket push)
// EMAIL: if enabled + user has verified email
if (prefs.isEmailEnabled() && user.getEmail() != null) {
emailService.sendNotificationEmail(user.getEmail(), notification);
}
// WEB_PUSH: if enabled + has registered Web Push subscriptions
if (prefs.isWebPushEnabled()) {
var webTokens = deviceTokenRepo.findByUserIdAndPlatform(user.getId(), WEB);
webTokens.forEach(dt -> webPushSender.send(dt.getToken(), toPushPayload(notification)));
}
// MOBILE_PUSH: if enabled + has registered mobile tokens
if (prefs.isMobilePushEnabled()) {
var mobileTokens = deviceTokenRepo.findByUserIdAndPlatformIn(user.getId(), List.of(IOS, ANDROID));
mobileTokens.forEach(dt -> fcmPushSender.send(dt.getToken(), toPushPayload(notification)));
}
}
}
Unified push payload:
@Data @Builder
public class PushPayload {
private String title;
private String body;
private String type; // NotificationType enum value
private String icon; // "/icons/icon-192.png"
private String badge; // "/icons/icon-192.png"
private String url; // click action URL
private Map<String, String> data; // custom data (e.g. distributionId, topicId)
}
Integration with existing NotificationService:
- Modify
NotificationService.sendBroadcast()andsendToSelected()to calldispatchService.dispatch(notification, user)after persisting each notification - Use
@Asyncfor dispatch to avoid blocking the main transaction
Dependencies: Steps 1.13, 1.14, existing NotificationService
Acceptance criteria: When a notification is created, it fans out to all enabled channels. Disabled channels are skipped. Missing device tokens gracefully handled (no error).
Step 1.16 — Backend + Frontend: Web Push (VAPID)
Backend files to create/modify:
cannamanage-service/src/main/java/de/cannamanage/service/push/WebPushSender.java(implementation)
Configuration (application.properties):
# Web Push VAPID keys (generate once, store in env)
push.vapid.public-key=${VAPID_PUBLIC_KEY}
push.vapid.private-key=${VAPID_PRIVATE_KEY}
push.vapid.subject=mailto:admin@cannamanage.de
VAPID key generation (one-time setup):
npx web-push generate-vapid-keys
WebPushSender implementation:
- Uses
nl.martijndwars:web-push-javalibrary (orcom.interaso:webpush) for Java Web Push - Accepts the subscription JSON stored in
DeviceToken.token, parses endpoint + keys - Sends push payload encrypted per Web Push protocol
Frontend files to modify:
sw.js— Add push event handler + notification click handler- New:
cannamanage-frontend/src/lib/push-subscription.ts— VAPID subscription helper - New:
cannamanage-frontend/src/components/push-permission-prompt.tsx— Permission request UI
Service worker additions (sw.js):
// Handle incoming push messages
self.addEventListener("push", (event) => {
const data = event.data ? event.data.json() : {}
const options = {
body: data.body || "Neue Benachrichtigung",
icon: data.icon || "/icons/icon-192.png",
badge: "/icons/icon-192.png",
tag: data.type || "default",
data: { url: data.url || "/portal/notifications", ...data.data },
actions: data.actions || [{ action: "open", title: "Anzeigen" }],
vibrate: [100, 50, 100],
}
event.waitUntil(self.registration.showNotification(data.title || "CannaManage", options))
})
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = event.notification.data?.url || "/portal/notifications"
event.waitUntil(
clients.matchAll({ type: "window" }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(url) && "focus" in client) return client.focus()
}
return clients.openWindow(url)
})
)
})
Frontend push subscription flow:
// push-subscription.ts
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return null
const registration = await navigator.serviceWorker.ready
const permission = await Notification.requestPermission()
if (permission !== "granted") return null
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
// Send subscription to backend
await fetch("/api/v1/notifications/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
platform: "WEB",
token: JSON.stringify(subscription),
deviceName: navigator.userAgent.includes("Mobile") ? "Mobile Browser" : "Desktop Browser",
}),
})
return subscription
}
Push permission prompt (delayed — not on first login):
- NOT shown on first login. Instead, shown after the user has experienced value: triggered on second portal login OR after first meaningful interaction (e.g., after viewing a distribution, after reading an info board post). This avoids aggressive prompting before users understand what the platform does.
- "Möchtest du Push-Benachrichtigungen aktivieren? Du wirst über neue Ankündigungen, Abgabetermine und Forum-Antworten informiert." (German explanation of practical value)
- Accept/Decline buttons
- On accept: calls
subscribeToPush(), updates notification preferences toWEB_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.
FcmPushSenderis 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):
- Create Firebase project "cannamanage-prod" in Firebase Console
- Enable Cloud Messaging API
- Generate service account key JSON
- Store in production: environment variable
GOOGLE_APPLICATION_CREDENTIALSpointing to mounted secret - Store in dev: local file at
~/.cannamanage/firebase-service-account.json
Configuration (application.properties):
# Firebase Cloud Messaging — ALWAYS ACTIVE in production
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
FcmPushSender implementation:
@Slf4j
@Service
public class FcmPushSender {
private FirebaseMessaging messaging;
@Value("${push.fcm.credentials-path:}")
private String credentialsPath;
@PostConstruct
void init() {
if (credentialsPath == null || credentialsPath.isBlank()) {
log.warn("FCM credentials not configured — push notifications to mobile devices disabled");
return;
}
try {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(
new FileInputStream(credentialsPath)))
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
}
messaging = FirebaseMessaging.getInstance();
log.info("Firebase Cloud Messaging initialized successfully");
} catch (Exception e) {
log.error("Failed to initialize FCM: {}", e.getMessage());
}
}
public void send(String fcmToken, PushPayload payload) {
if (messaging == null) {
log.debug("FCM not initialized — skipping push to token {}", fcmToken.substring(0, 10));
return;
}
Message message = Message.builder()
.setToken(fcmToken)
.setNotification(Notification.builder()
.setTitle(payload.getTitle())
.setBody(payload.getBody())
.setImage(payload.getIcon())
.build())
.putAllData(payload.getData())
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.build())
.setApnsConfig(ApnsConfig.builder()
.setAps(Aps.builder().setSound("default").setBadge(1).build())
.build())
.build();
try {
messaging.send(message);
} catch (FirebaseMessagingException e) {
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
deviceTokenRepo.deleteByToken(fcmToken);
log.warn("Removed expired FCM token");
} else if (isTransientError(e)) {
// Retry with exponential backoff: 1s, 2s, 4s (max 3 attempts)
retryWithBackoff(() -> messaging.send(message), 3, fcmToken);
} else {
log.error("FCM send failed permanently: {}", e.getMessage());
}
}
}
}
Key points:
- No
@ConditionalOnProperty— FCM bean is always created; gracefully degrades if credentials missing - Firebase project created in Sprint 7 as part of initial setup
- Handles
UNREGISTEREDerror 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/deviceswithplatform=IOS/ANDROID - Backend dispatch + send logic is fully operational NOW
Maven dependency:
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
</dependency>
Dependencies: Step 1.15 (dispatch service) Acceptance criteria: FCM sender initializes at application startup with valid credentials. Sends push to registered FCM tokens. Handles token expiry by auto-removal. Gracefully degrades in dev without credentials (logs warning, skips send).
Phase 2: Info Board (Schwarzes Brett)
Implements a one-to-many announcement system where staff posts and members read.
Step 2.1 — Flyway migration V13: Info Board tables
Files to create:
cannamanage-api/src/main/resources/db/migration/V13__info_board.sql
SQL: (from analysis document section 1.4)
info_board_categories— name, color, sort_order, tenant_idinfo_board_posts— title, body (TEXT), category_id, author_id, pinned, archived, published_at, tenant_idinfo_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.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/InfoBoardPost.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/InfoBoardAttachment.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/InfoBoardCategoryType.java
Approach:
- All extend
AbstractTenantEntity InfoBoardPosthas@ManyToOnetoInfoBoardCategory,@OneToManytoInfoBoardAttachmentInfoBoardPostaddsupdatedAtfield with@PreUpdatelifecycle 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.javacannamanage-service/src/main/java/de/cannamanage/service/repository/InfoBoardPostRepository.javacannamanage-service/src/main/java/de/cannamanage/service/repository/InfoBoardAttachmentRepository.java
Key methods:
InfoBoardPostRepository.findByArchivedFalseOrderByPinnedDescPublishedAtDesc(Pageable)— listing with pinned-first sortInfoBoardPostRepository.findByCategoryIdAndArchivedFalse(UUID, Pageable)— category filterInfoBoardCategoryRepository.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.javacannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/CreateInfoBoardPostRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/InfoBoardPostResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/infoboard/InfoBoardCategoryResponse.javacannamanage-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
InfoBoardControllerwith 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
InfoBoardAttachmentrecord - 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 + managementcannamanage-frontend/src/app/(dashboard-layout)/info-board/new/page.tsx— Create/edit post formcannamanage-frontend/src/services/info-board.ts— API servicecannamanage-frontend/src/components/info-board/post-card.tsx— Post card componentcannamanage-frontend/src/components/info-board/category-badge.tsx— Colored category badgecannamanage-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
bodycolumn (TEXT type). Renders viadangerouslySetInnerHTMLwith DOMPurify sanitization on display. - Uses shadcn/ui components:
Card,Button,Tabs,Dialog,DropdownMenu - React Query:
useQueryfor listing,useMutationfor 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 pagecannamanage-frontend/src/components/portal/announcements-widget.tsx— Dashboard widget (latest 3 posts)
Approach:
- Portal dashboard: widget shows latest 3 non-archived posts (pinned first) with "View All" link
- Full page: paginated list of posts, category filter, markdown rendered body
- Pinned indicator (📌), category color badge
- Read-only for portal members (no edit/archive actions)
Dependencies: Step 2.7 (shared components like post-card, category-badge)
Acceptance criteria: Portal members see announcements. Pinned posts appear first. Category filter works.
Phase 2.5: Club Event Calendar
Implements a club-level event system with RSVP, calendar view, and iCal export. Integrates with the Phase 1 notification infrastructure for event reminders.
graph TD
EV[ClubEvent Entity] --> ES[EventService]
ES --> EC[EventController]
ES --> NS[NotificationService - reminders]
ES --> IC[iCal Generator]
RSVP[EventRsvp Entity] --> ES
EC --> FE_ADMIN[Admin Calendar Page]
EC --> FE_PORTAL[Portal Events Widget]
Step 2.5.1 — Extend enums for events
Files to modify:
Approach: Add new enum constants:
// NotificationType — add:
EVENT_CREATED,
EVENT_REMINDER,
EVENT_CANCELLED
// AuditEventType — add:
EVENT_CREATED,
EVENT_UPDATED,
EVENT_CANCELLED,
EVENT_RSVP
// StaffPermission — reuse MANAGE_INFO_BOARD for events (shared "club communication" scope)
// No new permission needed — events are part of club communication management
Dependencies: None (leaf change) Acceptance criteria: Enums compile, no existing functionality breaks.
Step 2.5.2 — Flyway migration V14: Event tables
Files to create:
cannamanage-api/src/main/resources/db/migration/V14__club_events.sql
SQL:
-- Club events
CREATE TABLE club_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
title VARCHAR(200) NOT NULL,
description TEXT,
event_type VARCHAR(50) NOT NULL, -- MEETING, HARVEST_FESTIVAL, BOARD_MEETING, WORKSHOP, OTHER
start_at TIMESTAMP WITH TIME ZONE NOT NULL,
end_at TIMESTAMP WITH TIME ZONE,
location VARCHAR(300),
max_attendees INTEGER,
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule VARCHAR(100), -- WEEKLY, BIWEEKLY, MONTHLY
recurrence_end_date DATE,
created_by UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_club_events_tenant_start ON club_events(tenant_id, start_at);
CREATE INDEX idx_club_events_type ON club_events(tenant_id, event_type);
-- Event RSVPs
CREATE TABLE event_rsvps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES club_events(id) ON DELETE CASCADE,
member_id UUID NOT NULL REFERENCES members(id),
status VARCHAR(20) NOT NULL, -- ACCEPTED, DECLINED, MAYBE
responded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL,
UNIQUE(event_id, member_id)
);
CREATE INDEX idx_event_rsvps_event ON event_rsvps(event_id);
CREATE INDEX idx_event_rsvps_member ON event_rsvps(member_id);
Dependencies: V12 (info_board) must run first Acceptance criteria: Migration succeeds. Tables created with correct foreign keys, unique constraints, and indexes.
Step 2.5.3 — Backend: Event entities and enums
Files to create:
cannamanage-domain/src/main/java/de/cannamanage/domain/entity/ClubEvent.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/EventRsvp.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/EventType.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/RsvpStatus.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/RecurrenceRule.java
Approach:
// EventType enum
public enum EventType {
MEETING, // Mitgliederversammlung
HARVEST_FESTIVAL, // Erntefest
BOARD_MEETING, // Vorstandssitzung
WORKSHOP, // Workshop
OTHER // Sonstiges
}
// RsvpStatus enum
public enum RsvpStatus { ACCEPTED, DECLINED, MAYBE }
// RecurrenceRule enum
public enum RecurrenceRule { WEEKLY, BIWEEKLY, MONTHLY }
// ClubEvent entity
@Entity @Table(name = "club_events")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class ClubEvent extends AbstractTenantEntity {
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, length = 50)
private EventType eventType;
@Column(name = "start_at", nullable = false)
private Instant startAt;
@Column(name = "end_at")
private Instant endAt;
@Column(length = 300)
private String location;
private Integer maxAttendees;
@Column(name = "is_recurring", nullable = false)
private boolean recurring;
@Enumerated(EnumType.STRING)
@Column(name = "recurrence_rule", length = 100)
private RecurrenceRule recurrenceRule;
@Column(name = "recurrence_end_date")
private LocalDate recurrenceEndDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by", nullable = false)
private User createdBy;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "club_id", nullable = false)
private Club club;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<EventRsvp> rsvps = new ArrayList<>();
}
// EventRsvp entity
@Entity @Table(name = "event_rsvps",
uniqueConstraints = @UniqueConstraint(columnNames = {"event_id", "member_id"}))
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class EventRsvp extends AbstractTenantEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private ClubEvent event;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private RsvpStatus status;
@Column(name = "responded_at", nullable = false)
private Instant respondedAt;
}
Dependencies: Step 2.5.2 Acceptance criteria: Entities validate at startup. CRUD operations work. Unique constraint on (event_id, member_id) enforced.
Step 2.5.4 — Backend: Event repositories
Files to create:
cannamanage-service/src/main/java/de/cannamanage/service/repository/ClubEventRepository.javacannamanage-service/src/main/java/de/cannamanage/service/repository/EventRsvpRepository.java
Key methods:
ClubEventRepository.findByTenantIdAndStartAtBetweenOrderByStartAtAsc(UUID, Instant, Instant)— date range query for calendar viewClubEventRepository.findByTenantIdAndStartAtAfterOrderByStartAtAsc(UUID, Instant, Pageable)— upcoming eventsEventRsvpRepository.findByEventId(UUID)— all RSVPs for an eventEventRsvpRepository.findByEventIdAndMemberId(UUID, UUID)— existing RSVP checkEventRsvpRepository.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: sendEVENT_CREATEDnotification to all club members (broadcast) - On
cancelEvent: sendEVENT_CANCELLEDnotification to all members who RSVP'd ACCEPTED/MAYBE - RSVP respects
maxAttendees— if event is full, ACCEPTED rejected (returnEVENT_FULLerror); MAYBE and DECLINED always allowed - Recurring event expansion: generate virtual event instances between
fromandtodates based onrecurrenceRule+recurrenceEndDate - iCal generation: standard RFC 5545 VCALENDAR/VEVENT format with DTSTART, DTEND, SUMMARY, DESCRIPTION, LOCATION, RRULE
iCal generation approach:
public String generateIcal(UUID eventId) {
var event = repository.findById(eventId).orElseThrow();
var sb = new StringBuilder();
sb.append("BEGIN:VCALENDAR\r\n");
sb.append("VERSION:2.0\r\n");
sb.append("PRODID:-//CannaManage//Events//EN\r\n");
sb.append("BEGIN:VEVENT\r\n");
sb.append("UID:").append(event.getId()).append("@cannamanage.de\r\n");
sb.append("DTSTART:").append(formatIcalDate(event.getStartAt())).append("\r\n");
if (event.getEndAt() != null) {
sb.append("DTEND:").append(formatIcalDate(event.getEndAt())).append("\r\n");
}
sb.append("SUMMARY:").append(escapeIcal(event.getTitle())).append("\r\n");
if (event.getDescription() != null) {
sb.append("DESCRIPTION:").append(escapeIcal(event.getDescription())).append("\r\n");
}
if (event.getLocation() != null) {
sb.append("LOCATION:").append(escapeIcal(event.getLocation())).append("\r\n");
}
if (event.isRecurring() && event.getRecurrenceRule() != null) {
sb.append("RRULE:FREQ=").append(toIcalFreq(event.getRecurrenceRule()));
if (event.getRecurrenceEndDate() != null) {
sb.append(";UNTIL=").append(formatIcalDate(event.getRecurrenceEndDate()));
}
sb.append("\r\n");
}
sb.append("END:VEVENT\r\n");
sb.append("END:VCALENDAR\r\n");
return sb.toString();
}
Dependencies: Step 2.5.4, NotificationService (Phase 1) Acceptance criteria: Full CRUD works. RSVP enforces max attendees. Recurring events expand correctly. iCal output validates against RFC 5545.
Step 2.5.6 — Backend: EventController
Files to create:
cannamanage-api/src/main/java/de/cannamanage/api/controller/EventController.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/CreateEventRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/UpdateEventRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/EventDetailResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/event/RsvpResponse.java
Endpoints:
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /api/v1/events |
MANAGE_INFO_BOARD |
Create event |
| GET | /api/v1/events |
Any authenticated | List events (query params: from, to) |
| GET | /api/v1/events/{id} |
Any authenticated | Event detail + attendee list |
| PUT | /api/v1/events/{id} |
MANAGE_INFO_BOARD |
Update event |
| DELETE | /api/v1/events/{id} |
MANAGE_INFO_BOARD |
Cancel event |
| POST | /api/v1/events/{id}/rsvp |
Any member | RSVP (accept/decline/maybe) |
| GET | /api/v1/events/{id}/ical |
Any authenticated | Download .ics file |
| GET | /api/v1/portal/events |
Portal member | Upcoming events for member's club |
Request DTOs:
public record CreateEventRequest(
@NotBlank @Size(max = 200) String title,
String description,
@NotNull EventType eventType,
@NotNull Instant startAt,
Instant endAt,
@Size(max = 300) String location,
Integer maxAttendees,
boolean recurring,
RecurrenceRule recurrenceRule, // required if recurring=true
LocalDate recurrenceEndDate // optional, defaults to 1 year out
) {}
public record RsvpRequest(
@NotNull RsvpStatus status // ACCEPTED, DECLINED, MAYBE
) {}
Dependencies: Step 2.5.5
Acceptance criteria: Permission checks enforced. iCal endpoint returns text/calendar content type. RSVP returns 409 if event is full.
Step 2.5.7 — Backend: Event reminder scheduler
Files to create:
cannamanage-service/src/main/java/de/cannamanage/service/EventReminderScheduler.java
Approach:
@Slf4j
@Service
public class EventReminderScheduler {
@Scheduled(fixedRate = 3600000) // Every hour
public void sendUpcomingEventReminders() {
var now = Instant.now();
var in24h = now.plus(24, ChronoUnit.HOURS);
var in25h = now.plus(25, ChronoUnit.HOURS);
// Find events starting between 24-25 hours from now (1-hour window to avoid duplicates)
var upcomingEvents = eventRepository.findByStartAtBetween(in24h, in25h);
for (var event : upcomingEvents) {
// Send EVENT_REMINDER to all members who RSVP'd ACCEPTED or MAYBE
var attendees = rsvpRepository.findByEventIdAndStatusIn(
event.getId(), List.of(RsvpStatus.ACCEPTED, RsvpStatus.MAYBE));
for (var rsvp : attendees) {
notificationService.sendToUser(
rsvp.getMember().getUserId(),
"Erinnerung: " + event.getTitle(),
"Morgen um " + formatTime(event.getStartAt()) + " — " + event.getLocation(),
"/portal/events/" + event.getId(),
NotificationType.EVENT_REMINDER
);
}
log.info("Sent {} reminders for event {}", attendees.size(), event.getId());
}
}
}
Dependencies: Step 2.5.5, NotificationService + DispatchService (Phase 1B) Acceptance criteria: Reminder sent ~24h before event. Only ACCEPTED/MAYBE attendees notified. Reminder uses multi-channel dispatch (in-app + push if enabled).
Step 2.5.8 — Frontend: Admin calendar page
Files to create:
cannamanage-frontend/src/app/(dashboard-layout)/calendar/page.tsx— Month calendar view + event listcannamanage-frontend/src/app/(dashboard-layout)/calendar/new/page.tsx— Create event formcannamanage-frontend/src/app/(dashboard-layout)/calendar/[eventId]/page.tsx— Event detail + attendee listcannamanage-frontend/src/services/events.ts— API servicecannamanage-frontend/src/components/calendar/month-grid.tsx— Simple month grid componentcannamanage-frontend/src/components/calendar/event-card.tsx— Event card for list viewcannamanage-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 pagecannamanage-frontend/src/app/(portal-layout)/portal/events/[eventId]/page.tsx— Event detail + RSVPcannamanage-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
useMutationwith optimistic update - If event is full (max_attendees reached): show "Ausgebucht" badge, disable RSVP
Dependencies: Step 2.5.6 (API), Step 2.5.8 (shared components) Acceptance criteria: Portal members see upcoming events. RSVP updates immediately. Full event shows "Ausgebucht" when at capacity.
Step 2.5.10 — Frontend: Navigation update for Calendar
Files to modify:
Approach: Add "Kalender" to the Communication nav group (after "Anbau" in sidebar, as specified):
{
title: "Communication",
items: [
{ title: "Info Board", href: "/info-board", iconName: "Megaphone" },
{ title: "Kalender", href: "/calendar", iconName: "Calendar" },
{ title: "Forum", href: "/forum", iconName: "MessageSquare" },
{ title: "Benachrichtigungen", href: "/settings/notifications", iconName: "Bell" },
],
}
Dependencies: Step 2.5.8 (calendar page exists) Acceptance criteria: "Kalender" appears in sidebar navigation. Link works.
Step 2.5.11 — Integration: Auto-post events to Info Board
Files to modify:
EventService.java— on event creation, also create an Info Board post of type EVENT
Approach:
- When
createEvent()is called, additionally callinfoBoardService.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
postToInfoBoardboolean 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_idforum_topics— title, body, category_id, author_id, pinned, locked, reply_count, last_reply_at, last_reply_by, tenant_idforum_replies— topic_id, body, author_id, edited_at, tenant_idforum_reports— reporter_id, target_type, target_id, reason, resolved, resolved_by, resolved_at, tenant_idforum_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.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumTopic.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReply.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReport.javacannamanage-domain/src/main/java/de/cannamanage/domain/entity/ForumReaction.javacannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumTargetType.java— enum: TOPIC, REPLYcannamanage-domain/src/main/java/de/cannamanage/domain/enums/ForumReactionType.java— enum: THUMBSUP, LEAF, FIRE, HEART
Approach:
- All extend
AbstractTenantEntity ForumTopichas denormalizedreplyCount,lastReplyAt,lastReplyByfieldsForumTopic.updatedAtfor edit trackingForumReply.editedAtfor edit indicator in UIForumReactionhas a@UniqueConstrainton (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.javacannamanage-service/src/main/java/de/cannamanage/service/repository/ForumTopicRepository.javacannamanage-service/src/main/java/de/cannamanage/service/repository/ForumReplyRepository.javacannamanage-service/src/main/java/de/cannamanage/service/repository/ForumReportRepository.javacannamanage-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 queueForumReactionRepository.countByTargetTypeAndTargetIdGroupByReaction(...)— reaction countsForumReactionRepository.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,editedAtis set and UI shows "(bearbeitet)" indicator. - On
createReply: incrementtopic.replyCount, settopic.lastReplyAtandtopic.lastReplyBy— must be in same@Transactionalscope as the reply INSERT to prevent inconsistency. - On
createReply: sendFORUM_REPLYnotification 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(), andlockTopic(): 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_idinforum_reportsis NEVER exposed to the reported content's author or to staff with onlyMODERATE_FORUMpermission. Only users withADMINrole can see reporter identity. TheReportResponseDTO has two variants:ReportResponseAdmin(includesreporterName) andReportResponse(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.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/CreateTopicRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/TopicResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/TopicDetailResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReplyResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/CreateReplyRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReportRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReportResponse.javacannamanage-api/src/main/java/de/cannamanage/api/dto/forum/ReactionRequest.javacannamanage-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 categorycannamanage-frontend/src/app/(dashboard-layout)/forum/topics/[topicId]/page.tsx— Topic detail + repliescannamanage-frontend/src/app/(dashboard-layout)/forum/topics/new/page.tsx— New topic formcannamanage-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 entrycannamanage-frontend/src/app/(portal-layout)/portal/forum/[categoryId]/page.tsx— Portal categorycannamanage-frontend/src/app/(portal-layout)/portal/forum/topics/[topicId]/page.tsx— Portal topic detailcannamanage-frontend/src/services/forum.ts— API service
Shared components:
cannamanage-frontend/src/components/forum/topic-card.tsx— Topic list itemcannamanage-frontend/src/components/forum/reply-card.tsx— Single replycannamanage-frontend/src/components/forum/reaction-bar.tsx— Emoji reaction buttons with countscannamanage-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
MarkdownEditorfrom 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_FORUMpermission) - 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.javaForumService.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.tscannamanage-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— addAuditService.log(...)callsForumService.java— addAuditService.log(...)calls
Approach: Log the following events using existing AuditService:
INFO_BOARD_POST_CREATED— on post creationINFO_BOARD_POST_EDITED— on post updateINFO_BOARD_POST_PINNED— on pin toggleINFO_BOARD_POST_ARCHIVED— on archiveFORUM_TOPIC_CREATED— on topic creationFORUM_TOPIC_LOCKED— on lockFORUM_TOPIC_DELETED— on deleteFORUM_REPLY_DELETED— on reply deleteFORUM_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 limitsForumService.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
SubscriptionRepositoryto 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— wire up for notification emailsapplication-production.properties— SMTP config
Files to create:
cannamanage-api/src/main/resources/templates/notification-email.txt— notification email templatecannamanage-api/src/main/resources/db/migration/V14__member_email_notifications.sql— opt-in column
Spring Boot mail configuration (application-production.properties):
# IONOS SMTP relay (plate-software.de)
spring.mail.host=smtp.ionos.de
spring.mail.port=587
spring.mail.username=${IONOS_SMTP_USER:noreply@cannamanage.plate-software.de}
spring.mail.password=${IONOS_SMTP_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
# Default sender address
cannamanage.mail.from=${MAIL_FROM:noreply@cannamanage.plate-software.de}
cannamanage.mail.reply-to=${MAIL_REPLY_TO:support@cannamanage.plate-software.de}
IONOS setup steps (one-time):
- Create mailbox
noreply@cannamanage.plate-software.dein IONOS admin panel - Set SPF record for
cannamanage.plate-software.de:v=spf1 include:_spf.perfora.net include:_spf.kundenserver.de ~all - Enable DKIM signing in IONOS DNS settings
- Store SMTP credentials as Docker secrets in production
EmailService integration:
- On broadcast notification send: if member has
EMAILchannel 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/preferencesto disable email channel - Rate limiting: max 50 emails per broadcast batch (IONOS limits). Implementation uses Resilience4j
RateLimiterconfigured at 50 permits per 60-second window. TheEmailServiceuses a dedicatedTaskExecutorwith a fixed thread pool of 2 threads. Broadcast sends are chunked into batches of 50, with a 1-secondThread.sleep()between batches. Configuration:resilience4j.ratelimiter.instances.ionos-smtp.limitForPeriod=50/limitRefreshPeriod=60s/timeoutDuration=30s. This prevents IONOS throttling (HTTP 429 / SMTP 451) during large broadcasts.
Flyway migration V16:
-- No longer needed as separate column — email opt-in is handled by notification_preferences table (channel=EMAIL, enabled=true/false)
-- This migration adds an index for faster email dispatch queries
CREATE INDEX idx_notification_preferences_email_enabled
ON notification_preferences(tenant_id, channel, enabled)
WHERE channel = 'EMAIL' AND enabled = true;
Dependencies: Phase 1 broadcast, Phase 1B notification preferences (channel-based opt-in already covers email) Acceptance criteria: Admin sends broadcast → members with EMAIL channel enabled receive email via IONOS SMTP. Email arrives with correct FROM address. Members without email opt-in receive nothing. IONOS rate limits respected.
Step 4.6 — Enterprise tier: Custom FROM address (DNS-verified)
Decision (2026-06-13): Enterprise tier clubs can configure a custom FROM address (e.g.,
vorstand@gruener-daumen.de) verified via DNS TXT record. Starter/Pro tiers always send fromnoreply@cannamanage.plate-software.de.
Files to create:
cannamanage-service/src/main/java/de/cannamanage/service/CustomMailDomainService.javacannamanage-api/src/main/java/de/cannamanage/api/controller/MailSettingsController.javacannamanage-api/src/main/java/de/cannamanage/api/dto/mail/CustomMailDomainRequest.javacannamanage-api/src/main/java/de/cannamanage/api/dto/mail/MailDomainStatusResponse.javacannamanage-api/src/main/resources/db/migration/V17__custom_mail_domains.sql
Flyway migration V17:
CREATE TABLE custom_mail_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL UNIQUE,
from_address VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
verification_token VARCHAR(64) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false,
verified_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
Verification flow:
- Enterprise admin enters desired FROM address (e.g.,
info@gruener-daumen.de) - System extracts domain, generates unique verification token
- Admin is instructed to add DNS TXT record:
cannamanage-verify=<token> - Admin clicks "Verify" → system does DNS TXT lookup via
InetAddress/javax.naming.directory - If record found and matches → mark as verified
- 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.deWhat 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.javacannamanage-service/src/test/java/de/cannamanage/service/ForumServiceTest.javacannamanage-service/src/test/java/de/cannamanage/service/NotificationServiceBroadcastTest.javacannamanage-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.javacannamanage-api/src/test/java/de/cannamanage/api/controller/ForumControllerIntegrationTest.javacannamanage-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.tscannamanage-frontend/e2e/forum.spec.tscannamanage-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_BOARDcan't create/edit/archive posts - Staff without
MODERATE_FORUMcan't lock/delete/pin topics or resolve reports - Staff without
SEND_NOTIFICATIONScan'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 |