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:
+95
@@ -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())
|
||||
));
|
||||
}
|
||||
}
|
||||
+85
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
+72
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
) {}
|
||||
+14
@@ -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
|
||||
) {}
|
||||
+12
@@ -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);
|
||||
Reference in New Issue
Block a user