From f028ec5796d06ba89207bd15806dd4472f1d921b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Thu, 14 May 2026 21:50:51 +0000 Subject: [PATCH] fix(notifications): panic-level defensive GET /me/notifications --- .../web/MeNotificationsController.java | 134 ++++++++++++------ 1 file changed, 88 insertions(+), 46 deletions(-) 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 5e90578..0043d98 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 @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import java.time.OffsetDateTime; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -67,58 +68,99 @@ public class MeNotificationsController { public Map list( @RequestParam(required = false) String status, @RequestParam(defaultValue = "20") int limit) { - - String currentUserSub = requireCurrentUserSub(); - String emailKey = currentUserEmail(); - int safeLimit = Math.min(limit, MAX_LIMIT); - - // 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; + // Panic-level defensiveness: this endpoint is hit by the bell badge + // poll on EVERY page (every 30s). A 500 here breaks global UX, even + // for users who don't care about notifications. + // + // Strategy: wrap the entire handler in try/catch. Anonymous request + // OR unauthenticated context returns empty cleanly (no 403 throw — + // bell на logged-out landing not worth a guard violation). Any other + // failure also returns empty + logs the cause for follow-up. 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(); - } + String sub = currentUserSubSafe(); + if (sub == null) { + // Anonymous / no JWT → no notifications possible. Don't throw 403, + // just say "no notifications" — semantically true. + return emptyResponse(); + } - long totalUnread = 0L; + String emailKey = currentUserEmailSafe(); + if (emailKey == null || emailKey.isBlank()) { + // Authenticated but no email claim. Recipient lookup uses email, + // so without it we have nothing to fetch. + log.debug("No email claim in JWT for user sub={}", sub); + return emptyResponse(); + } + + int safeLimit = Math.min(Math.max(limit, 1), MAX_LIMIT); + + List items; + try { + Page page = notificationLogRepository.findByRecipientOrderBySentAtDesc( + emailKey, PageRequest.of(0, safeLimit)); + items = page.getContent().stream() + .filter(n -> status == null || (n.getStatus() != null && status.equalsIgnoreCase(n.getStatus().name()))) + .map(n -> { + try { + return toDto(n, sub); + } 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={}: {}", + sub, emailKey, e.getMessage(), e); + items = List.of(); + } + + long totalUnread = 0L; + try { + totalUnread = countUnread(sub, emailKey); + } catch (RuntimeException e) { + log.error("Failed to count unread notifications for user sub={}: {}", + sub, e.getMessage(), e); + } + + Map response = new HashMap<>(); + response.put("items", items); + response.put("totalUnread", totalUnread); + return response; + } catch (RuntimeException e) { + // Last-resort catch: anything we missed. Don't propagate to the + // global handler (which would 500). Log and return empty. + log.error("Unexpected error in GET /me/notifications", e); + return emptyResponse(); + } + } + + private static Map emptyResponse() { + Map r = new HashMap<>(); + r.put("items", List.of()); + r.put("totalUnread", 0L); + return r; + } + + /** Returns sub claim from JWT or null if missing/anonymous. Never throws. */ + private String currentUserSubSafe() { try { - totalUnread = countUnread(currentUserSub, emailKey); + return currentUserSub(); } 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. + log.warn("Failed to read JWT sub claim: {}", e.getMessage()); + return null; } + } - return Map.of( - "items", items, - "totalUnread", totalUnread - ); + /** Returns email claim from JWT or null. Never throws. */ + private String currentUserEmailSafe() { + try { + return currentUserEmail(); + } catch (RuntimeException e) { + log.warn("Failed to read JWT email claim: {}", e.getMessage()); + return null; + } } /**