feat(notifications): TODO 7 — draft decision toast + email + bell badge

This commit is contained in:
Александр Зимин
2026-05-14 14:40:35 +00:00
parent 50d263745a
commit 9050e2427e
21 changed files with 1992 additions and 82 deletions
@@ -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
) {}
}