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,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);
}
});
}
}
@@ -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());
}
}
}
@@ -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");
}
}
@@ -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);
}
@@ -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();
}
@@ -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);
}
@@ -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);
}
@@ -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);
}