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:
+81
@@ -0,0 +1,81 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.DeviceToken;
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import de.cannamanage.service.repository.DeviceTokenRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages device token registration for push notifications.
|
||||
* Max 10 devices per user to prevent abuse.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceRegistrationService {
|
||||
|
||||
private static final int MAX_DEVICES_PER_USER = 10;
|
||||
|
||||
private final DeviceTokenRepository deviceTokenRepository;
|
||||
|
||||
/**
|
||||
* Register a device token (upsert: if token exists, update lastUsedAt).
|
||||
*/
|
||||
@Transactional
|
||||
public DeviceToken registerDevice(UUID userId, DevicePlatform platform, String token, String deviceName) {
|
||||
// Check if this exact token already exists for this user
|
||||
var existing = deviceTokenRepository.findByUserIdAndToken(userId, token);
|
||||
if (existing.isPresent()) {
|
||||
var dt = existing.get();
|
||||
dt.setLastUsedAt(Instant.now());
|
||||
dt.setDeviceName(deviceName);
|
||||
log.debug("Updated existing device token for user {}", userId);
|
||||
return deviceTokenRepository.save(dt);
|
||||
}
|
||||
|
||||
// Enforce max device limit
|
||||
long count = deviceTokenRepository.countByUserId(userId);
|
||||
if (count >= MAX_DEVICES_PER_USER) {
|
||||
throw new IllegalStateException("Maximum device limit (" + MAX_DEVICES_PER_USER + ") reached");
|
||||
}
|
||||
|
||||
var deviceToken = DeviceToken.builder()
|
||||
.userId(userId)
|
||||
.platform(platform)
|
||||
.token(token)
|
||||
.deviceName(deviceName)
|
||||
.lastUsedAt(Instant.now())
|
||||
.build();
|
||||
|
||||
log.debug("Registered new {} device for user {}", platform, userId);
|
||||
return deviceTokenRepository.save(deviceToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered devices for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<DeviceToken> getDevices(UUID userId) {
|
||||
return deviceTokenRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a device by ID (owner check done in controller).
|
||||
*/
|
||||
@Transactional
|
||||
public void unregisterDevice(UUID deviceId, UUID userId) {
|
||||
deviceTokenRepository.findById(deviceId).ifPresent(dt -> {
|
||||
if (dt.getUserId().equals(userId)) {
|
||||
deviceTokenRepository.delete(dt);
|
||||
log.debug("Unregistered device {} for user {}", deviceId, userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Notification;
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
import de.cannamanage.service.push.FcmPushSender;
|
||||
import de.cannamanage.service.push.PushPayload;
|
||||
import de.cannamanage.service.push.WebPushSender;
|
||||
import de.cannamanage.service.repository.DeviceTokenRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Multi-channel notification dispatch service.
|
||||
* When a notification is created, fans out to all enabled channels for the user.
|
||||
* Runs asynchronously to avoid blocking the main transaction.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationDispatchService {
|
||||
|
||||
private final NotificationPreferenceService preferenceService;
|
||||
private final DeviceTokenRepository deviceTokenRepository;
|
||||
private final WebPushSender webPushSender;
|
||||
private final FcmPushSender fcmPushSender;
|
||||
private final EmailService emailService;
|
||||
|
||||
/**
|
||||
* Dispatch a notification to all enabled channels for a user.
|
||||
* IN_APP is already handled by NotificationService (WebSocket push).
|
||||
* This handles the additional channels: EMAIL, WEB_PUSH, MOBILE_PUSH.
|
||||
*/
|
||||
@Async
|
||||
public void dispatch(Notification notification, UUID userId) {
|
||||
try {
|
||||
// Build push payload
|
||||
var payload = PushPayload.builder()
|
||||
.title(notification.getTitle())
|
||||
.body(notification.getMessage())
|
||||
.type(notification.getType().name())
|
||||
.icon("/icons/icon-192.png")
|
||||
.badge("/icons/icon-192.png")
|
||||
.url(notification.getLink() != null ? notification.getLink() : "/portal/notifications")
|
||||
.data(Map.of("notificationId", notification.getId().toString()))
|
||||
.build();
|
||||
|
||||
// WEB_PUSH
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.WEB_PUSH)) {
|
||||
var webTokens = deviceTokenRepository.findByUserIdAndPlatform(userId, DevicePlatform.WEB);
|
||||
for (var dt : webTokens) {
|
||||
webPushSender.send(dt.getToken(), payload);
|
||||
}
|
||||
if (!webTokens.isEmpty()) {
|
||||
log.debug("Dispatched Web Push to {} devices for user {}", webTokens.size(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
// MOBILE_PUSH
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.MOBILE_PUSH)) {
|
||||
var mobileTokens = deviceTokenRepository.findByUserIdAndPlatformIn(
|
||||
userId, List.of(DevicePlatform.IOS, DevicePlatform.ANDROID));
|
||||
for (var dt : mobileTokens) {
|
||||
fcmPushSender.send(dt.getToken(), payload);
|
||||
}
|
||||
if (!mobileTokens.isEmpty()) {
|
||||
log.debug("Dispatched FCM push to {} devices for user {}", mobileTokens.size(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
// EMAIL (queue — actual email sending deferred to Phase 4 IONOS integration)
|
||||
if (preferenceService.isChannelEnabled(userId, NotificationChannel.EMAIL)) {
|
||||
log.debug("Email notification channel enabled for user {} — email sending deferred to Phase 4", userId);
|
||||
// emailService.sendNotificationEmail(user.getEmail(), notification);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error dispatching notification {} to user {}: {}", notification.getId(), userId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.NotificationPreference;
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
import de.cannamanage.service.repository.NotificationPreferenceRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages per-user notification channel preferences.
|
||||
* IN_APP is always enabled and cannot be disabled.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationPreferenceService {
|
||||
|
||||
private final NotificationPreferenceRepository preferenceRepository;
|
||||
|
||||
/**
|
||||
* Get preferences for a user, creating defaults if none exist.
|
||||
*/
|
||||
@Transactional
|
||||
public List<NotificationPreference> getOrCreatePreferences(UUID userId) {
|
||||
var prefs = preferenceRepository.findByUserId(userId);
|
||||
if (prefs.isEmpty()) {
|
||||
prefs = createDefaultPreferences(userId);
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single channel preference. IN_APP cannot be disabled.
|
||||
*/
|
||||
@Transactional
|
||||
public NotificationPreference updatePreference(UUID userId, NotificationChannel channel, boolean enabled) {
|
||||
// IN_APP cannot be disabled
|
||||
if (channel == NotificationChannel.IN_APP && !enabled) {
|
||||
throw new IllegalArgumentException("IN_APP notifications cannot be disabled");
|
||||
}
|
||||
|
||||
var pref = preferenceRepository.findByUserIdAndChannel(userId, channel)
|
||||
.orElseGet(() -> NotificationPreference.builder()
|
||||
.userId(userId)
|
||||
.channel(channel)
|
||||
.enabled(false)
|
||||
.build());
|
||||
|
||||
pref.setEnabled(enabled);
|
||||
return preferenceRepository.save(pref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific channel is enabled for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isChannelEnabled(UUID userId, NotificationChannel channel) {
|
||||
if (channel == NotificationChannel.IN_APP) return true;
|
||||
return preferenceRepository.findByUserIdAndChannel(userId, channel)
|
||||
.map(NotificationPreference::isEnabled)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private List<NotificationPreference> createDefaultPreferences(UUID userId) {
|
||||
var defaults = List.of(
|
||||
NotificationPreference.builder().userId(userId).channel(NotificationChannel.IN_APP).enabled(true).build(),
|
||||
NotificationPreference.builder().userId(userId).channel(NotificationChannel.EMAIL).enabled(false).build(),
|
||||
NotificationPreference.builder().userId(userId).channel(NotificationChannel.WEB_PUSH).enabled(false).build(),
|
||||
NotificationPreference.builder().userId(userId).channel(NotificationChannel.MOBILE_PUSH).enabled(false).build()
|
||||
);
|
||||
return preferenceRepository.saveAll(defaults);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Notification;
|
||||
import de.cannamanage.domain.entity.NotificationSend;
|
||||
import de.cannamanage.domain.entity.NotificationSendRecipient;
|
||||
import de.cannamanage.domain.enums.NotificationType;
|
||||
import de.cannamanage.domain.enums.TargetType;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.NotificationRepository;
|
||||
import de.cannamanage.service.repository.NotificationSendRepository;
|
||||
import de.cannamanage.service.repository.NotificationSendRecipientRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Notification service — persists notifications and delivers in real-time via WebSocket.
|
||||
* Extended in Sprint 7 with broadcast and targeted send capabilities.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -22,7 +31,11 @@ import java.util.UUID;
|
||||
public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final NotificationSendRepository notificationSendRepository;
|
||||
private final NotificationSendRecipientRepository sendRecipientRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final NotificationDispatchService dispatchService;
|
||||
|
||||
/**
|
||||
* Send a notification: persist to DB + push via WebSocket.
|
||||
@@ -33,8 +46,77 @@ public class NotificationService {
|
||||
notification = notificationRepository.save(notification);
|
||||
|
||||
// Push to user's WebSocket queue
|
||||
pushToWebSocket(notification);
|
||||
|
||||
// Dispatch to additional channels (Web Push, FCM, Email) asynchronously
|
||||
dispatchService.dispatch(notification, userId);
|
||||
|
||||
log.debug("Notification sent to user {}: {} - {}", userId, type, title);
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a broadcast notification to ALL active members in the current tenant.
|
||||
*/
|
||||
@Transactional
|
||||
public NotificationSend sendBroadcast(String title, String message, String link, UUID authorId) {
|
||||
// Get all active member user IDs in the current tenant
|
||||
var memberUserIds = memberRepository.findAllActiveUserIds();
|
||||
|
||||
return sendToRecipients(title, message, link, authorId, memberUserIds, TargetType.ALL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a targeted notification to selected members.
|
||||
*/
|
||||
@Transactional
|
||||
public NotificationSend sendToSelected(String title, String message, String link, UUID authorId, List<UUID> recipientIds) {
|
||||
return sendToRecipients(title, message, link, authorId, recipientIds, TargetType.SELECTED);
|
||||
}
|
||||
|
||||
private NotificationSend sendToRecipients(String title, String message, String link, UUID authorId,
|
||||
List<UUID> recipientIds, TargetType targetType) {
|
||||
// Create the send record
|
||||
var send = NotificationSend.builder()
|
||||
.title(title)
|
||||
.message(message)
|
||||
.link(link)
|
||||
.authorId(authorId)
|
||||
.targetType(targetType)
|
||||
.targetCount(recipientIds.size())
|
||||
.readCount(0)
|
||||
.sentAt(Instant.now())
|
||||
.build();
|
||||
send = notificationSendRepository.save(send);
|
||||
|
||||
// Create individual notifications for each recipient
|
||||
var recipients = new ArrayList<NotificationSendRecipient>();
|
||||
for (UUID userId : recipientIds) {
|
||||
var notification = new Notification(userId, NotificationType.ADMIN_MESSAGE, title, message, link);
|
||||
notification = notificationRepository.save(notification);
|
||||
|
||||
recipients.add(NotificationSendRecipient.builder()
|
||||
.sendId(send.getId())
|
||||
.userId(userId)
|
||||
.notificationId(notification.getId())
|
||||
.build());
|
||||
|
||||
// Push via WebSocket
|
||||
pushToWebSocket(notification);
|
||||
|
||||
// Dispatch to push channels asynchronously
|
||||
dispatchService.dispatch(notification, userId);
|
||||
}
|
||||
|
||||
sendRecipientRepository.saveAll(recipients);
|
||||
|
||||
log.info("Broadcast sent: {} recipients, type={}, title='{}'", recipientIds.size(), targetType, title);
|
||||
return send;
|
||||
}
|
||||
|
||||
private void pushToWebSocket(Notification notification) {
|
||||
messagingTemplate.convertAndSendToUser(
|
||||
userId.toString(),
|
||||
notification.getUserId().toString(),
|
||||
"/queue/notifications",
|
||||
Map.of(
|
||||
"id", notification.getId(),
|
||||
@@ -46,9 +128,6 @@ public class NotificationService {
|
||||
"createdAt", notification.getCreatedAt().toString()
|
||||
)
|
||||
);
|
||||
|
||||
log.debug("Notification sent to user {}: {} - {}", userId, type, title);
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.cannamanage.service.push;
|
||||
|
||||
import de.cannamanage.service.repository.DeviceTokenRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Firebase Cloud Messaging push sender.
|
||||
* Sends push notifications to Android/iOS devices via FCM.
|
||||
* <p>
|
||||
* Gracefully degrades if Firebase credentials are not configured —
|
||||
* logs a warning on startup but doesn't fail.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FcmPushSender {
|
||||
|
||||
@Value("${push.fcm.credentials-path:}")
|
||||
private String credentialsPath;
|
||||
|
||||
private final DeviceTokenRepository deviceTokenRepository;
|
||||
private boolean initialized = false;
|
||||
|
||||
public FcmPushSender(DeviceTokenRepository deviceTokenRepository) {
|
||||
this.deviceTokenRepository = deviceTokenRepository;
|
||||
}
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
if (credentialsPath == null || credentialsPath.isBlank()) {
|
||||
log.warn("FCM credentials not configured — push notifications to mobile devices disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Firebase Admin SDK initialization would go here:
|
||||
// FirebaseOptions options = FirebaseOptions.builder()
|
||||
// .setCredentials(GoogleCredentials.fromStream(new FileInputStream(credentialsPath)))
|
||||
// .build();
|
||||
// FirebaseApp.initializeApp(options);
|
||||
// messaging = FirebaseMessaging.getInstance();
|
||||
|
||||
// For MVP: mark as not initialized until firebase-admin is added to POM
|
||||
log.info("FCM credentials path configured: {} — SDK integration pending", credentialsPath);
|
||||
initialized = false;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize FCM: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification via FCM.
|
||||
* Handles expired tokens by removing them from the database.
|
||||
*/
|
||||
public void send(String fcmToken, PushPayload payload) {
|
||||
if (!initialized) {
|
||||
log.debug("FCM not initialized — skipping push to token {}",
|
||||
fcmToken.length() > 10 ? fcmToken.substring(0, 10) + "..." : fcmToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Firebase Admin SDK send logic:
|
||||
// Message message = Message.builder()
|
||||
// .setToken(fcmToken)
|
||||
// .setNotification(Notification.builder()
|
||||
// .setTitle(payload.getTitle())
|
||||
// .setBody(payload.getBody())
|
||||
// .build())
|
||||
// .putAllData(payload.getData() != null ? payload.getData() : Map.of())
|
||||
// .build();
|
||||
//
|
||||
// try {
|
||||
// messaging.send(message);
|
||||
// } catch (FirebaseMessagingException e) {
|
||||
// if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) {
|
||||
// deviceTokenRepository.deleteByToken(fcmToken);
|
||||
// log.warn("Removed expired FCM token");
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.cannamanage.service.push;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Unified push notification payload used across all push channels (Web Push, FCM, APNs).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class PushPayload {
|
||||
private String title;
|
||||
private String body;
|
||||
private String type;
|
||||
private String icon;
|
||||
private String badge;
|
||||
private String url;
|
||||
private Map<String, String> data;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.cannamanage.service.push;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Web Push sender using VAPID keys.
|
||||
* Sends push notifications to browser service workers via the Web Push protocol.
|
||||
* <p>
|
||||
* For MVP: uses raw HTTP push with simple JSON payload.
|
||||
* If credentials are not configured, logs a warning and skips.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class WebPushSender {
|
||||
|
||||
@Value("${push.vapid.public-key:}")
|
||||
private String vapidPublicKey;
|
||||
|
||||
@Value("${push.vapid.private-key:}")
|
||||
private String vapidPrivateKey;
|
||||
|
||||
@Value("${push.vapid.subject:mailto:admin@cannamanage.de}")
|
||||
private String vapidSubject;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
|
||||
/**
|
||||
* Send a push notification to a Web Push subscription.
|
||||
*
|
||||
* @param subscriptionJson the full subscription JSON (endpoint, keys.p256dh, keys.auth)
|
||||
* @param payload the notification payload
|
||||
*/
|
||||
public void send(String subscriptionJson, PushPayload payload) {
|
||||
if (vapidPublicKey.isBlank() || vapidPrivateKey.isBlank()) {
|
||||
log.debug("VAPID keys not configured — skipping Web Push");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract endpoint from subscription JSON (simple parsing without Jackson)
|
||||
var endpoint = extractJsonString(subscriptionJson, "endpoint");
|
||||
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
log.warn("Invalid Web Push subscription — missing endpoint");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build payload JSON manually
|
||||
var payloadJson = buildPayloadJson(payload);
|
||||
|
||||
// Simplified push: send payload to push endpoint
|
||||
// Full VAPID auth + encryption (RFC 8291) should be added when web-push-java lib is integrated
|
||||
var request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(endpoint))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("TTL", "86400")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payloadJson))
|
||||
.build();
|
||||
|
||||
var response = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString());
|
||||
response.thenAccept(r -> {
|
||||
if (r.statusCode() == 201) {
|
||||
log.debug("Web Push sent successfully to {}...", endpoint.substring(0, Math.min(50, endpoint.length())));
|
||||
} else if (r.statusCode() == 410) {
|
||||
log.info("Web Push subscription expired (410 Gone): {}...", endpoint.substring(0, Math.min(50, endpoint.length())));
|
||||
} else {
|
||||
log.warn("Web Push failed with status {}: {}", r.statusCode(), r.body());
|
||||
}
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send Web Push: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Web Push is configured and ready to send.
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return !vapidPublicKey.isBlank() && !vapidPrivateKey.isBlank();
|
||||
}
|
||||
|
||||
public String getPublicKey() {
|
||||
return vapidPublicKey;
|
||||
}
|
||||
|
||||
private String buildPayloadJson(PushPayload payload) {
|
||||
return "{" +
|
||||
"\"title\":\"" + escapeJson(payload.getTitle()) + "\"," +
|
||||
"\"body\":\"" + escapeJson(payload.getBody()) + "\"," +
|
||||
"\"icon\":\"" + escapeJson(payload.getIcon() != null ? payload.getIcon() : "/icons/icon-192.png") + "\"," +
|
||||
"\"badge\":\"/icons/icon-192.png\"," +
|
||||
"\"type\":\"" + escapeJson(payload.getType() != null ? payload.getType() : "default") + "\"," +
|
||||
"\"url\":\"" + escapeJson(payload.getUrl() != null ? payload.getUrl() : "/portal/notifications") + "\"" +
|
||||
"}";
|
||||
}
|
||||
|
||||
private String extractJsonString(String json, String key) {
|
||||
var searchKey = "\"" + key + "\"";
|
||||
int keyIdx = json.indexOf(searchKey);
|
||||
if (keyIdx < 0) return null;
|
||||
|
||||
int colonIdx = json.indexOf(':', keyIdx + searchKey.length());
|
||||
if (colonIdx < 0) return null;
|
||||
|
||||
int startQuote = json.indexOf('"', colonIdx + 1);
|
||||
if (startQuote < 0) return null;
|
||||
|
||||
int endQuote = json.indexOf('"', startQuote + 1);
|
||||
if (endQuote < 0) return null;
|
||||
|
||||
return json.substring(startQuote + 1, endQuote);
|
||||
}
|
||||
|
||||
private String escapeJson(String value) {
|
||||
if (value == null) return "";
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.DeviceToken;
|
||||
import de.cannamanage.domain.enums.DevicePlatform;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DeviceTokenRepository extends JpaRepository<DeviceToken, UUID> {
|
||||
|
||||
List<DeviceToken> findByUserId(UUID userId);
|
||||
|
||||
List<DeviceToken> findByUserIdAndPlatform(UUID userId, DevicePlatform platform);
|
||||
|
||||
List<DeviceToken> findByUserIdAndPlatformIn(UUID userId, List<DevicePlatform> platforms);
|
||||
|
||||
Optional<DeviceToken> findByUserIdAndToken(UUID userId, String token);
|
||||
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
void deleteByToken(String token);
|
||||
}
|
||||
+8
@@ -3,6 +3,7 @@ package de.cannamanage.service.repository;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
@@ -29,4 +30,11 @@ public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||
* Find all under-21 members for a tenant.
|
||||
*/
|
||||
List<Member> findByTenantIdAndUnder21True(UUID tenantId);
|
||||
|
||||
/**
|
||||
* Get all active member user IDs (for broadcast notifications).
|
||||
* Uses the Hibernate tenant filter, so no explicit tenantId parameter needed.
|
||||
*/
|
||||
@Query("SELECT m.userId FROM Member m WHERE m.status = 'ACTIVE'")
|
||||
List<UUID> findAllActiveUserIds();
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.NotificationPreference;
|
||||
import de.cannamanage.domain.enums.NotificationChannel;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface NotificationPreferenceRepository extends JpaRepository<NotificationPreference, UUID> {
|
||||
|
||||
List<NotificationPreference> findByUserId(UUID userId);
|
||||
|
||||
Optional<NotificationPreference> findByUserIdAndChannel(UUID userId, NotificationChannel channel);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.NotificationSendRecipient;
|
||||
import de.cannamanage.domain.entity.NotificationSendRecipientId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface NotificationSendRecipientRepository extends JpaRepository<NotificationSendRecipient, NotificationSendRecipientId> {
|
||||
|
||||
List<NotificationSendRecipient> findBySendId(UUID sendId);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.NotificationSend;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface NotificationSendRepository extends JpaRepository<NotificationSend, UUID> {
|
||||
|
||||
Page<NotificationSend> findAllByOrderBySentAtDesc(Pageable pageable);
|
||||
}
|
||||
Reference in New Issue
Block a user