feat(notifications): TODO 7 — draft decision toast + email + bell badge
This commit is contained in:
+213
@@ -0,0 +1,213 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Me-scoped notifications endpoints — current user sees their own notification log.
|
||||
*
|
||||
* <p>Pagination is cursor-less for simplicity (Phase A). The recipient column in
|
||||
* notification_log stores the email address, so we look up current user's email
|
||||
* from the JWT {@code email} claim as the lookup key.
|
||||
*
|
||||
* <p>Read state is tracked in {@code notification_read_state} table (migration 0024)
|
||||
* — a separate table keyed by (user_id, notification_id) so the log itself stays
|
||||
* append-only with no mutable state.
|
||||
*
|
||||
* <p>The {@code totalUnread} count is computed as notifications without a read_state
|
||||
* row for the current user.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me/notifications")
|
||||
public class MeNotificationsController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MeNotificationsController.class);
|
||||
private static final int MAX_LIMIT = 100;
|
||||
|
||||
private final NotificationLogRepository notificationLogRepository;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MeNotificationsController(
|
||||
NotificationLogRepository notificationLogRepository,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
this.notificationLogRepository = notificationLogRepository;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* List notifications for the current user.
|
||||
*
|
||||
* <p>Query param {@code status} filters by notification_log.status (e.g. SENT).
|
||||
* If omitted, returns all statuses. {@code limit} caps at 100.
|
||||
*
|
||||
* @return paginated response with items and totalUnread count
|
||||
*/
|
||||
@GetMapping
|
||||
public Map<String, Object> list(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
|
||||
String currentUserSub = requireCurrentUserSub();
|
||||
String emailKey = currentUserEmail();
|
||||
int safeLimit = Math.min(limit, MAX_LIMIT);
|
||||
|
||||
Page<NotificationLog> page;
|
||||
if (emailKey != null && !emailKey.isBlank()) {
|
||||
page = notificationLogRepository.findByRecipientOrderBySentAtDesc(
|
||||
emailKey, PageRequest.of(0, safeLimit));
|
||||
} else {
|
||||
// No email in JWT — return empty, not an error
|
||||
return Map.of("items", List.of(), "totalUnread", 0);
|
||||
}
|
||||
|
||||
List<NotificationItemDto> items = page.getContent().stream()
|
||||
.filter(n -> status == null || status.equalsIgnoreCase(n.getStatus().name()))
|
||||
.map(n -> toDto(n, currentUserSub))
|
||||
.toList();
|
||||
|
||||
long totalUnread = countUnread(currentUserSub, emailKey);
|
||||
|
||||
return Map.of(
|
||||
"items", items,
|
||||
"totalUnread", totalUnread
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification as read.
|
||||
*/
|
||||
@PostMapping("/{id}/read")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void markRead(@PathVariable Long id) {
|
||||
String sub = requireCurrentUserSub();
|
||||
markReadState(sub, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications for current user as read.
|
||||
*/
|
||||
@PostMapping("/read-all")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void markAllRead() {
|
||||
String sub = requireCurrentUserSub();
|
||||
String emailKey = currentUserEmail();
|
||||
if (emailKey == null || emailKey.isBlank()) return;
|
||||
|
||||
// Find all unread notification ids for this recipient and mark them read
|
||||
Page<NotificationLog> page = notificationLogRepository.findByRecipientOrderBySentAtDesc(
|
||||
emailKey, PageRequest.of(0, MAX_LIMIT));
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
page.getContent().forEach(n -> markReadState(sub, n.getId()));
|
||||
log.debug("Marked {} notifications read for user {}", page.getNumberOfElements(), sub);
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private void markReadState(String sub, Long notificationId) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO notification_read_state (user_id, notification_id, read_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (user_id, notification_id) DO NOTHING
|
||||
""", sub, notificationId, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
private long countUnread(String sub, String emailKey) {
|
||||
Long count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM notification_log nl
|
||||
WHERE nl.recipient = ?
|
||||
AND nl.status = 'SENT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_read_state rs
|
||||
WHERE rs.user_id = ? AND rs.notification_id = nl.id
|
||||
)
|
||||
""", Long.class, emailKey, sub);
|
||||
return count != null ? count : 0L;
|
||||
}
|
||||
|
||||
private NotificationItemDto toDto(NotificationLog n, String sub) {
|
||||
boolean read = isRead(sub, n.getId());
|
||||
String preview = buildPreview(n);
|
||||
return new NotificationItemDto(
|
||||
n.getId(),
|
||||
n.getEventType(),
|
||||
n.getChannel(),
|
||||
n.getSentAt(),
|
||||
preview,
|
||||
read
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isRead(String sub, Long notificationId) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM notification_read_state WHERE user_id = ? AND notification_id = ?",
|
||||
Integer.class, sub, notificationId);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
private static String buildPreview(NotificationLog n) {
|
||||
// Construct a human-readable preview from event_type + event metadata
|
||||
return switch (n.getEventType()) {
|
||||
case "RecordDraftApproved" -> "Draft approved";
|
||||
case "RecordDraftRejected" -> "Draft rejected";
|
||||
case "RecordDraftWithdrawn" -> "Draft withdrawn";
|
||||
case "RecordDraftSubmitted" -> "New draft submitted for review";
|
||||
default -> n.getEventType();
|
||||
};
|
||||
}
|
||||
|
||||
private String requireCurrentUserSub() {
|
||||
String sub = currentUserSub();
|
||||
if (sub == null) {
|
||||
throw cloud.nstart.terravault.ordinis.restapi.error.OrdinisException.forbidden(
|
||||
"unauthenticated", "Authentication required for /me/notifications");
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
|
||||
private String currentUserSub() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (!(auth instanceof JwtAuthenticationToken jwt)) return null;
|
||||
return jwt.getToken().getSubject();
|
||||
}
|
||||
|
||||
private String currentUserEmail() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (!(auth instanceof JwtAuthenticationToken jwt)) return null;
|
||||
Jwt token = jwt.getToken();
|
||||
return token.getClaimAsString("email");
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for a single notification item.
|
||||
*/
|
||||
public record NotificationItemDto(
|
||||
Long id,
|
||||
String eventType,
|
||||
String channel,
|
||||
OffsetDateTime sentAt,
|
||||
String payloadPreview,
|
||||
boolean read
|
||||
) {}
|
||||
}
|
||||
Reference in New Issue
Block a user