Kalkulator zdolności, integracja Moich ofert i system powiadomień

- Kalkulator zdolności kredytowej: nowy widok z live-obliczeniami (rata
  annuitetowa/malejąca, DTI/DSTI, rekomendacje, donut), wpięty w poradnik,
  panel i stronę główną; pasek boczny konta dla zalogowanych.
- Moje oferty: prawdziwe ogłoszenia (/listings/mine) scalone z listą,
  realne akcje Wstrzymaj/Wznów (PATCH /status, nowy status PAUSED),
  Usuń (DELETE) z kontrolą właściciela; realne wyświetlenia (viewsCount)
  i zapisane z ulubionych. Fix CHECK constraintu enuma w SchemaFixer.
- System powiadomień: encja Notification + serwis + kontroler + SSE
  realtime (token w query param w JwtAuthFilter). Podpięte źródła
  serwerowe (wiadomości, zmiana statusu ogłoszenia, zdarzenia konta)
  i klienckie (ulubione, spadek ceny, spotkania, weryfikacja telefonu).
  Centralny NotificationsProvider; dzwonek i strona bez zmian designu,
  klikalne z deep-linkami i oznaczaniem jako przeczytane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 17:02:25 +02:00
parent e9673551ce
commit 12fcb60b4c
18 changed files with 2314 additions and 112 deletions
@@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.message.MessageResponse;
import pl.polskalokalnie.message.MessageService;
import pl.polskalokalnie.message.SendMessageRequest;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.report.ListingReportResponse;
import pl.polskalokalnie.report.ListingReportService;
import pl.polskalokalnie.report.ResolveListingReportRequest;
@@ -52,13 +53,15 @@ public class AdminController {
private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder;
private final NotificationService notificationService;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService,
MessageService messageService,
TextModerationService textModerationService,
AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder) {
PasswordEncoder passwordEncoder,
NotificationService notificationService) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService;
@@ -67,6 +70,7 @@ public class AdminController {
this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService;
}
// --- Uzytkownicy ---
@@ -93,7 +97,12 @@ public class AdminController {
public UserResponse verify(@PathVariable Long id) {
AppUser user = requireUser(id);
user.setVerified(true);
return UserResponse.from(userRepository.save(user));
AppUser saved = userRepository.save(user);
notificationService.create(saved.getEmail(), "system", "check",
"Konto zweryfikowane",
"Twoje konto zostało zweryfikowane przez administratora. Masz dostęp do wszystkich funkcji serwisu.",
"accountSecurity", "account-verified");
return UserResponse.from(saved);
}
@PostMapping("/users/{id}/grant-admin")
@@ -104,7 +113,12 @@ public class AdminController {
}
user.setRole(Role.ADMIN);
user.setVerified(true);
return UserResponse.from(userRepository.save(user));
AppUser saved = userRepository.save(user);
notificationService.create(saved.getEmail(), "system", "shield",
"Nadano uprawnienia administratora",
"Twoje konto otrzymało rolę administratora.",
"account", null);
return UserResponse.from(saved);
}
@PostMapping("/users/{id}/revoke-admin")
@@ -137,6 +151,10 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane",
"Administrator zresetował hasło do Twojego konta. Zaloguj się nowym hasłem i ustaw własne w ustawieniach bezpieczeństwa.",
"accountSecurity", null);
return new ResetPasswordResponse(temporaryPassword);
}
@@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
String token = resolveToken(request);
if (token != null) {
try {
Claims claims = jwtService.parse(token);
String email = claims.getSubject();
@@ -48,4 +47,17 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
filterChain.doFilter(request, response);
}
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
String param = request.getParameter("access_token");
if (param != null && !param.isBlank()) {
return param;
}
return null;
}
}
@@ -48,6 +48,16 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT"
);
// Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
jdbcTemplate.execute(
"ALTER TABLE property_listings DROP CONSTRAINT IF EXISTS property_listings_status_check"
);
jdbcTemplate.execute(
"ALTER TABLE property_listings ADD CONSTRAINT property_listings_status_check "
+ "CHECK (status IN ('PENDING','APPROVED','REJECTED','PAUSED'))"
);
String dataUrlType = jdbcTemplate.query(
"SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'",
rs -> rs.next() ? rs.getString(1) : null
@@ -5,7 +5,9 @@ import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -55,4 +57,20 @@ public class ListingController {
.created(URI.create("/api/listings/" + response.id()))
.body(response);
}
// Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED).
@PatchMapping("/{id}/status")
public ListingResponse updateStatus(
@PathVariable Long id,
@RequestParam ListingStatus status,
Authentication authentication
) {
return listingService.setOwnStatus(id, authentication.getName(), status);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
listingService.deleteOwn(id, authentication.getName());
return ResponseEntity.noContent().build();
}
}
@@ -26,6 +26,7 @@ public record ListingResponse(
String coverPhoto,
String ownerEmail,
ListingStatus status,
Long viewsCount,
Instant createdAt
) {
public static ListingResponse from(PropertyListing listing) {
@@ -48,6 +49,7 @@ public record ListingResponse(
listing.getCoverPhoto(),
listing.getOwnerEmail(),
listing.getStatus(),
listing.getViewsCount(),
listing.getCreatedAt()
);
}
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
@Service
public class ListingService {
@@ -18,10 +19,13 @@ public class ListingService {
private final ListingRepository listingRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) {
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
}
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
@@ -56,6 +60,39 @@ public class ListingService {
.toList();
}
// --- Operacje wlasciciela na wlasnym ogloszeniu ---
// Wstrzymanie/wznowienie widocznosci wlasnego ogloszenia (tylko miedzy APPROVED i PAUSED).
@Transactional
public ListingResponse setOwnStatus(Long id, String ownerEmail, ListingStatus status) {
if (status != ListingStatus.APPROVED && status != ListingStatus.PAUSED) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieobslugiwana zmiana statusu");
}
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
requireOwner(listing, ownerEmail);
if (listing.getStatus() != ListingStatus.APPROVED && listing.getStatus() != ListingStatus.PAUSED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
}
listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing));
}
@Transactional
public void deleteOwn(Long id, String ownerEmail) {
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
requireOwner(listing, ownerEmail);
listingRepository.delete(listing);
}
private void requireOwner(PropertyListing listing, String ownerEmail) {
if (ownerEmail == null || listing.getOwnerEmail() == null
|| !ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak uprawnien do tego ogloszenia");
}
}
@Transactional
public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) {
textModerationService.validateOrThrow(
@@ -123,7 +160,32 @@ public class ListingService {
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing));
PropertyListing saved = listingRepository.save(listing);
notifyOwnerStatusChange(saved, status);
return ListingResponse.from(saved);
}
// Powiadomienie dla wlasciciela po decyzji moderacji (approve/reject).
private void notifyOwnerStatusChange(PropertyListing listing, ListingStatus status) {
if (listing.getOwnerEmail() == null) {
return;
}
String title;
String body;
String icon;
if (status == ListingStatus.APPROVED) {
title = "Ogłoszenie opublikowane";
body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało zatwierdzone i jest już widoczne w serwisie.";
icon = "check";
} else if (status == ListingStatus.REJECTED) {
title = "Ogłoszenie odrzucone";
body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało odrzucone przez moderację.";
icon = "warning";
} else {
return;
}
notificationService.create(listing.getOwnerEmail(), "listing", icon, title, body,
"listing:" + listing.getId(), "status-" + listing.getId() + "-" + status);
}
public void delete(Long id) {
@@ -3,5 +3,6 @@ package pl.polskalokalnie.listing;
public enum ListingStatus {
PENDING,
APPROVED,
REJECTED
REJECTED,
PAUSED
}
@@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@@ -15,15 +16,18 @@ public class MessageService {
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
public MessageService(
MessageRepository messageRepository,
UserRepository userRepository,
TextModerationService textModerationService
TextModerationService textModerationService,
NotificationService notificationService
) {
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
}
public AppUser resolveAdmin() {
@@ -46,9 +50,38 @@ public class MessageService {
message.setRecipientId(recipientId);
message.setContent(content.trim());
Message saved = messageRepository.save(message);
notifyRecipient(senderId, recipientId, saved.getContent());
return MessageResponse.from(saved, senderId);
}
// Powiadomienie dla odbiorcy kazdej wiadomosci: admin->user ("Wiadomosc od obslugi"),
// user->admin traktujemy jako zapytanie/wiadomosc do obslugi.
private void notifyRecipient(Long senderId, Long recipientId, String content) {
AppUser recipient = userRepository.findById(recipientId).orElse(null);
if (recipient == null) {
return;
}
AppUser sender = userRepository.findById(senderId).orElse(null);
String senderName = sender != null && sender.getFullName() != null && !sender.getFullName().isBlank()
? sender.getFullName()
: "użytkownika";
String preview = content.length() > 90 ? content.substring(0, 90) + "" : content;
if (recipient.getRole() == Role.ADMIN) {
notificationService.create(recipient.getEmail(), "messages", "message",
"Nowa wiadomość od użytkownika",
senderName + ": " + preview,
"messages", null);
} else {
notificationService.create(recipient.getEmail(), "messages", "mail",
"Wiadomość od obsługi Mieszko",
preview,
"messages:admin", null);
}
}
public long countUnreadFrom(Long recipientId, Long senderId) {
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.notification;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Zadanie utworzenia powiadomienia dla zalogowanego uzytkownika (zdarzenia liczone po stronie klienta:
* alerty cenowe, spotkania, ulubione, weryfikacja telefonu, dopasowane oferty).
* userEmail NIE jest przyjmowany z ciala - serwer ustawia go z tokenu.
*/
public record CreateNotificationRequest(
@NotBlank @Size(max = 20) String category,
@Size(max = 40) String icon,
@NotBlank @Size(max = 200) String title,
@Size(max = 600) String body,
@Size(max = 200) String link,
@Size(max = 160) String dedupeKey
) {
}
@@ -0,0 +1,128 @@
package pl.polskalokalnie.notification;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notifications_user", columnList = "userEmail"),
@Index(name = "idx_notifications_user_dedupe", columnList = "userEmail,dedupeKey")
})
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String userEmail;
// Kategoria zgodna z frontendem: listing | messages | system.
@Column(nullable = false, length = 20)
private String category;
@Column(nullable = false, length = 40)
private String icon;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 600)
private String body;
// Deskryptor deep-linku, np. "listing:12", "messages:admin", "priceAlerts", "meetings".
@Column(length = 200)
private String link;
// Klucz idempotencji - blokuje duplikaty tego samego zdarzenia (np. jeden spadek ceny).
@Column(length = 160)
private String dedupeKey;
@Column(nullable = false)
private boolean read = false;
@Column(nullable = false)
private Instant createdAt = Instant.now();
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public String getLink() {
return link;
}
public void setLink(String link) {
this.link = link;
}
public String getDedupeKey() {
return dedupeKey;
}
public void setDedupeKey(String dedupeKey) {
this.dedupeKey = dedupeKey;
}
public boolean isRead() {
return read;
}
public void setRead(boolean read) {
this.read = read;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,71 @@
package pl.polskalokalnie.notification;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
public NotificationController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@GetMapping
public List<NotificationResponse> list(Authentication authentication) {
return notificationService.listOwn(authentication.getName());
}
@GetMapping("/unread-count")
public UnreadCountResponse unreadCount(Authentication authentication) {
return new UnreadCountResponse(notificationService.unreadCount(authentication.getName()));
}
@PostMapping("/{id}/read")
public ResponseEntity<Void> markRead(@PathVariable Long id, Authentication authentication) {
notificationService.markRead(id, authentication.getName());
return ResponseEntity.noContent().build();
}
@PostMapping("/read-all")
public ResponseEntity<Void> markAllRead(Authentication authentication) {
notificationService.markAllRead(authentication.getName());
return ResponseEntity.noContent().build();
}
// Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu,
// dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce).
@PostMapping
public NotificationResponse create(@Valid @RequestBody CreateNotificationRequest request, Authentication authentication) {
Notification created = notificationService.create(
authentication.getName(),
request.category(),
request.icon(),
request.title(),
request.body(),
request.link(),
request.dedupeKey()
);
return created == null ? null : NotificationResponse.from(created);
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName());
}
public record UnreadCountResponse(long count) {
}
}
@@ -0,0 +1,20 @@
package pl.polskalokalnie.notification;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
long countByUserEmailIgnoreCaseAndReadFalse(String userEmail);
boolean existsByUserEmailIgnoreCaseAndDedupeKey(String userEmail, String dedupeKey);
@Modifying
@Query("update Notification n set n.read = true where lower(n.userEmail) = lower(:email) and n.read = false")
int markAllRead(@Param("email") String email);
}
@@ -0,0 +1,27 @@
package pl.polskalokalnie.notification;
import java.time.Instant;
public record NotificationResponse(
Long id,
String category,
String icon,
String title,
String body,
String link,
boolean read,
Instant createdAt
) {
public static NotificationResponse from(Notification n) {
return new NotificationResponse(
n.getId(),
n.getCategory(),
n.getIcon(),
n.getTitle(),
n.getBody(),
n.getLink(),
n.isRead(),
n.getCreatedAt()
);
}
}
@@ -0,0 +1,143 @@
package pl.polskalokalnie.notification;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* Centralny serwis powiadomien. Zapisuje powiadomienia per uzytkownik i wypycha je w czasie
* rzeczywistym przez SSE. Inne moduly (wiadomosci, ogloszenia, administracja) wolaja {@link #create}.
*/
@Service
public class NotificationService {
private static final long SSE_TIMEOUT_MS = 30L * 60L * 1000L;
private final NotificationRepository repository;
// email uzytkownika -> otwarte polaczenia SSE (moze byc kilka kart/urzadzen).
private final Map<String, CopyOnWriteArrayList<SseEmitter>> emitters = new ConcurrentHashMap<>();
public NotificationService(NotificationRepository repository) {
this.repository = repository;
}
@Transactional
public Notification create(String userEmail, String category, String icon, String title,
String body, String link, String dedupeKey) {
if (userEmail == null || userEmail.isBlank() || title == null || title.isBlank()) {
return null;
}
if (dedupeKey != null && !dedupeKey.isBlank()
&& repository.existsByUserEmailIgnoreCaseAndDedupeKey(userEmail, dedupeKey)) {
return null;
}
Notification notification = new Notification();
notification.setUserEmail(userEmail);
notification.setCategory(normalizeCategory(category));
notification.setIcon(icon == null || icon.isBlank() ? defaultIcon(category) : icon);
notification.setTitle(title.trim());
notification.setBody(body == null ? null : body.trim());
notification.setLink(link == null || link.isBlank() ? null : link.trim());
notification.setDedupeKey(dedupeKey == null || dedupeKey.isBlank() ? null : dedupeKey.trim());
Notification saved = repository.save(notification);
push(userEmail, NotificationResponse.from(saved));
return saved;
}
public List<NotificationResponse> listOwn(String email) {
return repository.findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(email).stream()
.map(NotificationResponse::from)
.toList();
}
public long unreadCount(String email) {
return repository.countByUserEmailIgnoreCaseAndReadFalse(email);
}
@Transactional
public void markRead(Long id, String email) {
Notification notification = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Powiadomienie nie istnieje"));
if (email == null || !email.equalsIgnoreCase(notification.getUserEmail())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do tego powiadomienia");
}
if (!notification.isRead()) {
notification.setRead(true);
repository.save(notification);
}
}
@Transactional
public void markAllRead(String email) {
repository.markAllRead(email);
}
// --- SSE ---
public SseEmitter subscribe(String email) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
CopyOnWriteArrayList<SseEmitter> list = emitters.computeIfAbsent(email, key -> new CopyOnWriteArrayList<>());
list.add(emitter);
emitter.onCompletion(() -> remove(email, emitter));
emitter.onTimeout(() -> remove(email, emitter));
emitter.onError(error -> remove(email, emitter));
try {
emitter.send(SseEmitter.event().name("ready").data("ok"));
} catch (IOException ex) {
remove(email, emitter);
}
return emitter;
}
private void push(String email, NotificationResponse payload) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(email);
if (list == null) {
return;
}
for (SseEmitter emitter : list) {
try {
emitter.send(SseEmitter.event().name("notification").data(payload));
} catch (Exception ex) {
remove(email, emitter);
}
}
}
private void remove(String email, SseEmitter emitter) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(email);
if (list != null) {
list.remove(emitter);
if (list.isEmpty()) {
emitters.remove(email, list);
}
}
}
private String normalizeCategory(String category) {
if ("listing".equals(category) || "messages".equals(category) || "system".equals(category)) {
return category;
}
return "system";
}
private String defaultIcon(String category) {
if ("messages".equals(category)) {
return "message";
}
if ("listing".equals(category)) {
return "house";
}
return "bell";
}
}