diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsController.java index 76e4f10..5e90578 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsController.java @@ -72,21 +72,48 @@ public class MeNotificationsController { String emailKey = currentUserEmail(); int safeLimit = Math.min(limit, MAX_LIMIT); - Page page; - if (emailKey != null && !emailKey.isBlank()) { - page = notificationLogRepository.findByRecipientOrderBySentAtDesc( - emailKey, PageRequest.of(0, safeLimit)); - } else { - // No email in JWT — return empty, not an error + // Hardened against runtime exceptions — endpoint hit от bell on каждой + // странице; 500 здесь сломает global UX. Каждая под-операция wraped + // в try/catch с structured log + sensible fallback (empty list / 0 count). + // Backend can still see issues via logs; user sees empty drawer instead + // of "Внутренняя ошибка сервера". + + if (emailKey == null || emailKey.isBlank()) { + // No email claim in JWT — can't look up notifications. Return empty. + log.debug("No email claim in JWT for user sub={}, returning empty notifications", currentUserSub); return Map.of("items", List.of(), "totalUnread", 0); } - List items = page.getContent().stream() - .filter(n -> status == null || status.equalsIgnoreCase(n.getStatus().name())) - .map(n -> toDto(n, currentUserSub)) - .toList(); + List items; + try { + Page page = notificationLogRepository.findByRecipientOrderBySentAtDesc( + emailKey, PageRequest.of(0, safeLimit)); + items = page.getContent().stream() + .filter(n -> status == null || status.equalsIgnoreCase(n.getStatus().name())) + .map(n -> { + try { + return toDto(n, currentUserSub); + } catch (RuntimeException e) { + log.warn("Failed to map NotificationLog id={} to DTO: {}", n.getId(), e.getMessage()); + return null; + } + }) + .filter(java.util.Objects::nonNull) + .toList(); + } catch (RuntimeException e) { + log.error("Failed to load notifications for user sub={}, email={}: {}", + currentUserSub, emailKey, e.getMessage(), e); + items = List.of(); + } - long totalUnread = countUnread(currentUserSub, emailKey); + long totalUnread = 0L; + try { + totalUnread = countUnread(currentUserSub, emailKey); + } catch (RuntimeException e) { + log.error("Failed to count unread notifications for user sub={}: {}", + currentUserSub, e.getMessage(), e); + // Fallback to 0 — better to under-count than show user a 500. + } return Map.of( "items", items, @@ -118,7 +145,6 @@ public class MeNotificationsController { Page 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); } @@ -160,10 +186,13 @@ public class MeNotificationsController { } private boolean isRead(String sub, Long notificationId) { - Integer count = jdbcTemplate.queryForObject( + // PostgreSQL COUNT(*) returns BIGINT — request Long.class to avoid + // narrowing-conversion edge cases в Spring's RowMapper. Was Integer.class + // which Spring auto-converts via rs.getInt() (works but type-imprecise). + Long count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM notification_read_state WHERE user_id = ? AND notification_id = ?", - Integer.class, sub, notificationId); - return count != null && count > 0; + Long.class, sub, notificationId); + return count != null && count > 0L; } private static String buildPreview(NotificationLog n) {