12fcb60b4c
- 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>
144 lines
5.2 KiB
Java
144 lines
5.2 KiB
Java
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";
|
|
}
|
|
}
|