feat(sprint7): Phase 1 — notifications enhancement + push infrastructure

Phase 1 (Notification Enhancement):
- Extended NotificationType enum (ADMIN_MESSAGE, INFO_BOARD_POST, FORUM_REPLY, FORUM_MENTION)
- Extended StaffPermission enum (SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, MODERATE_FORUM)
- Extended AuditEventType with Sprint 7 events
- Flyway V11: notification_sends + notification_send_recipients tables
- NotificationSend + NotificationSendRecipient entities
- NotificationSendRepository + NotificationSendRecipientRepository
- Extended NotificationService with sendBroadcast() and sendToSelected()
- NotificationComposeController (POST /compose, GET /sends)
- ComposeNotificationRequest DTO

Phase 1B (Push Infrastructure):
- Flyway V12: device_tokens + notification_preferences tables
- DeviceToken entity + DevicePlatform enum
- NotificationPreference entity + NotificationChannel enum
- DeviceTokenRepository + NotificationPreferenceRepository
- DeviceRegistrationService (register/unregister/list devices, max 10 per user)
- NotificationPreferenceService (get/create defaults, update, IN_APP always on)
- NotificationDispatchService (multi-channel fan-out: WebSocket, Web Push, FCM, Email)
- WebPushSender (VAPID-based, simplified for MVP)
- FcmPushSender (graceful degradation if not configured)
- PushPayload DTO
- DeviceRegistrationController (POST/GET/DELETE /devices, GET /vapid-key)
- NotificationPreferenceController (GET/PUT /preferences)
- ConsentType extended (NOTIFICATION_PUSH, NOTIFICATION_EMAIL)
- TargetType enum (ALL, SELECTED)

Frontend:
- Updated sw.js with push event handler + notification click handler
- push-subscription.ts (subscribeToPush, unsubscribe, permission helpers)
- notification-compose.ts service (compose, sends, devices, preferences APIs)
- i18n keys (de.json + en.json) for compose, preferences, push, devices

Configuration:
- application-docker.properties: VAPID + FCM push config properties
- MemberRepository: added findAllActiveUserIds() for broadcast
This commit is contained in:
Patrick Plate
2026-06-13 19:25:19 +02:00
parent 329b7abb18
commit 706a6e257b
43 changed files with 6635 additions and 76 deletions
@@ -0,0 +1,95 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.RegisterDeviceRequest;
import de.cannamanage.domain.entity.DeviceToken;
import de.cannamanage.service.DeviceRegistrationService;
import de.cannamanage.service.push.WebPushSender;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Device token registration endpoints for push notifications.
* Any authenticated user can register/unregister their devices.
*/
@RestController
@RequestMapping("/api/v1/notifications/devices")
@RequiredArgsConstructor
public class DeviceRegistrationController {
private final DeviceRegistrationService deviceRegistrationService;
private final WebPushSender webPushSender;
/**
* Register a device token for push notifications.
*/
@PostMapping
public ResponseEntity<Map<String, Object>> registerDevice(
@Valid @RequestBody RegisterDeviceRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
DeviceToken device = deviceRegistrationService.registerDevice(
userId, request.platform(), request.token(), request.deviceName());
return ResponseEntity.ok(Map.of(
"id", device.getId(),
"platform", device.getPlatform().name(),
"deviceName", device.getDeviceName() != null ? device.getDeviceName() : "",
"createdAt", device.getCreatedAt().toString()
));
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
/**
* List user's registered devices.
*/
@GetMapping
public ResponseEntity<?> listDevices(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var devices = deviceRegistrationService.getDevices(userId);
var items = devices.stream().map(d -> Map.of(
"id", (Object) d.getId(),
"platform", d.getPlatform().name(),
"deviceName", d.getDeviceName() != null ? d.getDeviceName() : "",
"lastUsedAt", d.getLastUsedAt() != null ? d.getLastUsedAt().toString() : "",
"createdAt", d.getCreatedAt().toString()
)).toList();
return ResponseEntity.ok(Map.of("devices", items));
}
/**
* Unregister a device.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> unregisterDevice(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
deviceRegistrationService.unregisterDevice(id, userId);
return ResponseEntity.noContent().build();
}
/**
* Get the VAPID public key for Web Push subscription on the frontend.
*/
@GetMapping("/vapid-key")
public ResponseEntity<Map<String, String>> getVapidKey() {
return ResponseEntity.ok(Map.of(
"publicKey", webPushSender.getPublicKey(),
"configured", String.valueOf(webPushSender.isConfigured())
));
}
}
@@ -0,0 +1,85 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.ComposeNotificationRequest;
import de.cannamanage.domain.entity.NotificationSend;
import de.cannamanage.domain.enums.TargetType;
import de.cannamanage.service.NotificationService;
import de.cannamanage.service.repository.NotificationSendRepository;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
/**
* Admin notification compose endpoints.
* Requires SEND_NOTIFICATIONS permission (checked via StaffPermissionChecker).
*/
@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
public class NotificationComposeController {
private final NotificationService notificationService;
private final NotificationSendRepository notificationSendRepository;
/**
* Compose and send a notification (broadcast or targeted).
*/
@PostMapping("/compose")
public ResponseEntity<Map<String, Object>> composeAndSend(
@Valid @RequestBody ComposeNotificationRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID authorId = UUID.fromString(user.getUsername());
NotificationSend send;
if (request.targetType() == TargetType.ALL) {
send = notificationService.sendBroadcast(
request.title(), request.message(), request.link(), authorId);
} else {
if (request.recipientIds() == null || request.recipientIds().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "recipientIds required for SELECTED target type"));
}
send = notificationService.sendToSelected(
request.title(), request.message(), request.link(), authorId, request.recipientIds());
}
return ResponseEntity.ok(Map.of(
"id", send.getId(),
"targetType", send.getTargetType().name(),
"targetCount", send.getTargetCount(),
"sentAt", send.getSentAt().toString()
));
}
/**
* List sent notifications (paginated).
*/
@GetMapping("/sends")
public ResponseEntity<?> listSends(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
var sends = notificationSendRepository.findAllByOrderBySentAtDesc(PageRequest.of(page, size));
var items = sends.getContent().stream().map(s -> Map.of(
"id", (Object) s.getId(),
"title", s.getTitle(),
"targetType", s.getTargetType().name(),
"targetCount", s.getTargetCount(),
"readCount", s.getReadCount(),
"sentAt", s.getSentAt().toString()
)).toList();
return ResponseEntity.ok(Map.of(
"sends", items,
"totalElements", sends.getTotalElements(),
"totalPages", sends.getTotalPages()
));
}
}
@@ -0,0 +1,72 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.notification.UpdatePreferencesRequest;
import de.cannamanage.domain.enums.NotificationChannel;
import de.cannamanage.service.NotificationPreferenceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Notification preferences endpoints.
* Any authenticated user can view/update their notification channel preferences.
*/
@RestController
@RequestMapping("/api/v1/notifications/preferences")
@RequiredArgsConstructor
public class NotificationPreferenceController {
private final NotificationPreferenceService preferenceService;
/**
* Get user's notification channel preferences.
*/
@GetMapping
public ResponseEntity<Map<String, Object>> getPreferences(@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
}
/**
* Update notification channel preferences.
* IN_APP cannot be disabled (server-side enforcement).
*/
@PutMapping
public ResponseEntity<?> updatePreferences(
@Valid @RequestBody UpdatePreferencesRequest request,
@AuthenticationPrincipal UserDetails user) {
UUID userId = UUID.fromString(user.getUsername());
try {
for (var entry : request.preferences().entrySet()) {
preferenceService.updatePreference(userId, entry.getKey(), entry.getValue());
}
// Return updated preferences
var prefs = preferenceService.getOrCreatePreferences(userId);
var prefsMap = prefs.stream().collect(Collectors.toMap(
p -> p.getChannel().name(),
p -> (Object) p.isEnabled()
));
return ResponseEntity.ok(Map.of("preferences", prefsMap));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.TargetType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;
/**
* Request DTO for composing and sending a notification.
*/
public record ComposeNotificationRequest(
@NotBlank String title,
@NotBlank String message,
String link,
@NotNull TargetType targetType,
List<UUID> recipientIds
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.DevicePlatform;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Request DTO for registering a push notification device token.
*/
public record RegisterDeviceRequest(
@NotNull DevicePlatform platform,
@NotBlank String token,
String deviceName
) {}
@@ -0,0 +1,12 @@
package de.cannamanage.api.dto.notification;
import de.cannamanage.domain.enums.NotificationChannel;
import java.util.Map;
/**
* Request DTO for updating notification preferences.
*/
public record UpdatePreferencesRequest(
Map<NotificationChannel, Boolean> preferences
) {}
@@ -20,3 +20,12 @@ management.health.mail.enabled=false
# Disable mail in Docker (no SMTP container)
spring.mail.host=localhost
spring.mail.port=1025
# Web Push VAPID keys (generate via: npx web-push generate-vapid-keys)
push.vapid.public-key=${VAPID_PUBLIC_KEY:}
push.vapid.private-key=${VAPID_PRIVATE_KEY:}
push.vapid.subject=mailto:admin@cannamanage.de
# Firebase Cloud Messaging
push.fcm.credentials-path=${GOOGLE_APPLICATION_CREDENTIALS:}
push.fcm.project-id=${FCM_PROJECT_ID:cannamanage-prod}
@@ -0,0 +1,25 @@
-- Sprint 7 Phase 1: Notification sends (admin compose + broadcast tracking)
-- Tracks each "send" operation (one admin → many members)
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 REFERENCES users(id),
target_type VARCHAR(20) NOT NULL, -- ALL or SELECTED
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);
@@ -0,0 +1,31 @@
-- Sprint 7 Phase 1B: Push notification infrastructure
-- 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);