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,40 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.DevicePlatform;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
/**
* Push notification device token — stores Web Push subscriptions and mobile push tokens.
* A user can have multiple device tokens (multi-device support, max 10 per user).
*/
@Entity
@Table(name = "device_tokens", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "token"})
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DeviceToken extends AbstractTenantEntity {
@Column(name = "user_id", nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private DevicePlatform platform;
@Column(name = "token", nullable = false, columnDefinition = "TEXT")
private String token;
@Column(name = "device_name", length = 100)
private String deviceName;
@Column(name = "last_used_at")
private Instant lastUsedAt;
}
@@ -0,0 +1,44 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.NotificationChannel;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
/**
* Per-user notification channel preference.
* Controls which delivery channels are enabled for a user.
* IN_APP is always enabled and cannot be disabled.
*/
@Entity
@Table(name = "notification_preferences", uniqueConstraints = {
@UniqueConstraint(columnNames = {"user_id", "channel"})
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NotificationPreference extends AbstractTenantEntity {
@Column(name = "user_id", nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(name = "channel", nullable = false, length = 20)
private NotificationChannel channel;
@Column(name = "enabled", nullable = false)
private boolean enabled;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
}
@@ -0,0 +1,56 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.TargetType;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
/**
* Tracks a notification send operation (admin broadcast or targeted send).
* One NotificationSend creates many individual Notification records.
*/
@Entity
@Table(name = "notification_sends", indexes = {
@Index(name = "idx_notification_sends_tenant", columnList = "tenant_id, sent_at DESC")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NotificationSend extends AbstractTenantEntity {
@Column(name = "title", nullable = false)
private String title;
@Column(name = "message", nullable = false, columnDefinition = "TEXT")
private String message;
@Column(name = "link", length = 500)
private String link;
@Column(name = "author_id", nullable = false)
private UUID authorId;
@Enumerated(EnumType.STRING)
@Column(name = "target_type", nullable = false, length = 20)
private TargetType targetType;
@Column(name = "target_count", nullable = false)
private int targetCount;
@Column(name = "read_count", nullable = false)
private int readCount;
@Column(name = "sent_at", nullable = false)
private Instant sentAt;
@PrePersist
void onPersist() {
if (this.sentAt == null) {
this.sentAt = Instant.now();
}
}
}
@@ -0,0 +1,32 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serializable;
import java.util.UUID;
/**
* Join table entity: links a NotificationSend to individual recipients and their notifications.
*/
@Entity
@Table(name = "notification_send_recipients")
@IdClass(NotificationSendRecipientId.class)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NotificationSendRecipient {
@Id
@Column(name = "send_id", nullable = false)
private UUID sendId;
@Id
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "notification_id")
private UUID notificationId;
}
@@ -0,0 +1,34 @@
package de.cannamanage.domain.entity;
import java.io.Serializable;
import java.util.Objects;
import java.util.UUID;
/**
* Composite primary key for NotificationSendRecipient.
*/
public class NotificationSendRecipientId implements Serializable {
private UUID sendId;
private UUID userId;
public NotificationSendRecipientId() {}
public NotificationSendRecipientId(UUID sendId, UUID userId) {
this.sendId = sendId;
this.userId = userId;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NotificationSendRecipientId that = (NotificationSendRecipientId) o;
return Objects.equals(sendId, that.sendId) && Objects.equals(userId, that.userId);
}
@Override
public int hashCode() {
return Objects.hash(sendId, userId);
}
}
@@ -40,5 +40,21 @@ public enum AuditEventType {
SUBSCRIPTION_STARTED,
SUBSCRIPTION_CANCELED,
PAYMENT_RECEIVED,
PAYMENT_FAILED
PAYMENT_FAILED,
// Sprint 7 — Notification events
NOTIFICATION_BROADCAST_SENT,
// Sprint 7 — Info Board events
INFO_BOARD_POST_CREATED,
INFO_BOARD_POST_EDITED,
INFO_BOARD_POST_PINNED,
INFO_BOARD_POST_ARCHIVED,
// Sprint 7 — Forum events
FORUM_TOPIC_CREATED,
FORUM_TOPIC_LOCKED,
FORUM_TOPIC_DELETED,
FORUM_REPLY_DELETED,
FORUM_REPORT_RESOLVED
}
@@ -6,5 +6,8 @@ package de.cannamanage.domain.enums;
public enum ConsentType {
DATA_PROCESSING,
MARKETING,
ANALYTICS
ANALYTICS,
// Sprint 7 — Push notification consent (GDPR Art. 7(1))
NOTIFICATION_PUSH,
NOTIFICATION_EMAIL
}
@@ -0,0 +1,10 @@
package de.cannamanage.domain.enums;
/**
* Platform types for push notification device tokens.
*/
public enum DevicePlatform {
WEB,
IOS,
ANDROID
}
@@ -0,0 +1,12 @@
package de.cannamanage.domain.enums;
/**
* Delivery channels for notifications.
* IN_APP is always enabled and cannot be disabled.
*/
public enum NotificationChannel {
IN_APP,
EMAIL,
WEB_PUSH,
MOBILE_PUSH
}
@@ -7,5 +7,10 @@ public enum NotificationType {
QUOTA_WARNING,
BATCH_RECALLED,
DISTRIBUTION_RECORDED,
SUBSCRIPTION_EXPIRING
SUBSCRIPTION_EXPIRING,
// Sprint 7:
ADMIN_MESSAGE,
INFO_BOARD_POST,
FORUM_REPLY,
FORUM_MENTION
}
@@ -13,5 +13,9 @@ public enum StaffPermission {
VIEW_STOCK,
RECORD_STOCK_IN,
VIEW_COMPLIANCE_REPORT,
MANAGE_GROW_CALENDAR
MANAGE_GROW_CALENDAR,
// Sprint 7:
SEND_NOTIFICATIONS,
MANAGE_INFO_BOARD,
MODERATE_FORUM
}
@@ -0,0 +1,9 @@
package de.cannamanage.domain.enums;
/**
* Target audience for a notification send.
*/
public enum TargetType {
ALL,
SELECTED
}