Alerty cenowe na serwerze - powiadomienia tylko w serwisie
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Alerty liczyla dotad przegladarka, wiec dzialaly wylacznie przy otwartej
stronie, a rynek porownywaly malymi literami ("wtorny" vs "Wtórny"),
przez co alert lokalizacji z wybranym rynkiem nigdy nie mial danych.
Teraz: tabele price_alerts i price_alert_events, /api/price-alerts oraz
codzienny przeglad o 03:00 (po nocnym zapisie srednich cen). Ogloszenie -
powiadomienie przy kazdej zmianie ceny; lokalizacja - przy zmianie
sredniej o co najmniej 2%. Wynik trafia wylacznie na liste powiadomien
uzytkownika; alerty nie wysylaja zadnych e-maili.
Alerty zalozone w przegladarce przenosimy raz przy pierwszym wejsciu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
|
||||
/**
|
||||
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
|
||||
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
|
||||
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
|
||||
*
|
||||
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "price_alerts")
|
||||
public class PriceAlert {
|
||||
|
||||
public enum Kind {
|
||||
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
|
||||
LISTING,
|
||||
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
|
||||
LOCATION
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private Kind kind;
|
||||
|
||||
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
|
||||
@Column(nullable = false, length = 200)
|
||||
private String label;
|
||||
|
||||
// --- alert ogloszenia ---
|
||||
|
||||
@Column(name = "listing_id")
|
||||
private Long listingId;
|
||||
|
||||
// --- alert lokalizacji ---
|
||||
|
||||
@Column(length = 120)
|
||||
private String city;
|
||||
|
||||
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
|
||||
@Column(length = 60)
|
||||
private String region;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "property_type", length = 20)
|
||||
private PropertyType propertyType;
|
||||
|
||||
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
|
||||
@Column(length = 20)
|
||||
private String market;
|
||||
|
||||
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
|
||||
@Column(name = "last_value")
|
||||
private Long lastValue;
|
||||
|
||||
@Column(name = "last_checked_at")
|
||||
private Instant lastCheckedAt;
|
||||
|
||||
@Column(name = "notifications_enabled", nullable = false)
|
||||
private boolean notificationsEnabled = true;
|
||||
|
||||
@Column(name = "created_at", 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 Kind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public PropertyType getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(PropertyType propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public String getMarket() {
|
||||
return market;
|
||||
}
|
||||
|
||||
public void setMarket(String market) {
|
||||
this.market = market;
|
||||
}
|
||||
|
||||
public Long getLastValue() {
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
public void setLastValue(Long lastValue) {
|
||||
this.lastValue = lastValue;
|
||||
}
|
||||
|
||||
public Instant getLastCheckedAt() {
|
||||
return lastCheckedAt;
|
||||
}
|
||||
|
||||
public void setLastCheckedAt(Instant lastCheckedAt) {
|
||||
this.lastCheckedAt = lastCheckedAt;
|
||||
}
|
||||
|
||||
public boolean isNotificationsEnabled() {
|
||||
return notificationsEnabled;
|
||||
}
|
||||
|
||||
public void setNotificationsEnabled(boolean notificationsEnabled) {
|
||||
this.notificationsEnabled = notificationsEnabled;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
|
||||
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
|
||||
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
|
||||
*/
|
||||
@Component
|
||||
public class PriceAlertChecker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
|
||||
|
||||
private final PriceAlertService priceAlertService;
|
||||
|
||||
public PriceAlertChecker(PriceAlertService priceAlertService) {
|
||||
this.priceAlertService = priceAlertService;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void checkDaily() {
|
||||
run();
|
||||
}
|
||||
|
||||
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
|
||||
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
|
||||
public void checkOnStartup() {
|
||||
run();
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
int notified = priceAlertService.checkAll();
|
||||
if (notified > 0) {
|
||||
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
|
||||
|
||||
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
|
||||
@RestController
|
||||
@RequestMapping("/api/price-alerts")
|
||||
public class PriceAlertController {
|
||||
|
||||
private final PriceAlertService priceAlertService;
|
||||
|
||||
public PriceAlertController(PriceAlertService priceAlertService) {
|
||||
this.priceAlertService = priceAlertService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PriceAlertResponse> list(Authentication authentication) {
|
||||
return priceAlertService.list(authentication.getName());
|
||||
}
|
||||
|
||||
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
|
||||
@GetMapping("/events")
|
||||
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
|
||||
return priceAlertService.events(authentication.getName(), days);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
|
||||
return priceAlertService.create(authentication.getName(), request);
|
||||
}
|
||||
|
||||
public record NotificationsRequest(boolean enabled) {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/notifications")
|
||||
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
|
||||
Authentication authentication) {
|
||||
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
|
||||
priceAlertService.delete(authentication.getName(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class PriceAlertDtos {
|
||||
|
||||
private PriceAlertDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
|
||||
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
|
||||
*/
|
||||
public record PriceAlertRequest(Long listingId, String label, String city, String region,
|
||||
String propertyType, String market) {
|
||||
}
|
||||
|
||||
/**
|
||||
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
|
||||
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
|
||||
*/
|
||||
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
|
||||
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
|
||||
boolean notificationsEnabled, Instant createdAt) {
|
||||
}
|
||||
|
||||
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
|
||||
long previousValue, long currentValue, Instant changedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
|
||||
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "price_alert_events")
|
||||
public class PriceAlertEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "alert_id", nullable = false)
|
||||
private Long alertId;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private PriceAlert.Kind kind;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String label;
|
||||
|
||||
@Column(name = "listing_id")
|
||||
private Long listingId;
|
||||
|
||||
@Column(name = "previous_value", nullable = false)
|
||||
private long previousValue;
|
||||
|
||||
@Column(name = "current_value", nullable = false)
|
||||
private long currentValue;
|
||||
|
||||
@Column(name = "changed_at", nullable = false)
|
||||
private Instant changedAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getAlertId() {
|
||||
return alertId;
|
||||
}
|
||||
|
||||
public void setAlertId(Long alertId) {
|
||||
this.alertId = alertId;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public PriceAlert.Kind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(PriceAlert.Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public long getPreviousValue() {
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
public void setPreviousValue(long previousValue) {
|
||||
this.previousValue = previousValue;
|
||||
}
|
||||
|
||||
public long getCurrentValue() {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
public void setCurrentValue(long currentValue) {
|
||||
this.currentValue = currentValue;
|
||||
}
|
||||
|
||||
public Instant getChangedAt() {
|
||||
return changedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
|
||||
|
||||
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
|
||||
|
||||
void deleteByAlertId(Long alertId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
|
||||
|
||||
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||
|
||||
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
|
||||
|
||||
List<PriceAlert> findByKind(PriceAlert.Kind kind);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.notification.NotificationService;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
|
||||
import pl.polskalokalnie.pricehistory.PriceSnapshotService;
|
||||
|
||||
/**
|
||||
* Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
|
||||
*
|
||||
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
|
||||
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
|
||||
* ruchu pocztowego.</p>
|
||||
*/
|
||||
@Service
|
||||
public class PriceAlertService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
|
||||
|
||||
/**
|
||||
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
|
||||
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
|
||||
* zmiana jest dla obserwujacego istotna.
|
||||
*/
|
||||
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
|
||||
|
||||
private final PriceAlertRepository alertRepository;
|
||||
private final PriceAlertEventRepository eventRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final PriceSnapshotService snapshotService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public PriceAlertService(PriceAlertRepository alertRepository,
|
||||
PriceAlertEventRepository eventRepository,
|
||||
ListingRepository listingRepository,
|
||||
PriceSnapshotService snapshotService,
|
||||
NotificationService notificationService) {
|
||||
this.alertRepository = alertRepository;
|
||||
this.eventRepository = eventRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.snapshotService = snapshotService;
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PriceAlertResponse> list(String userEmail) {
|
||||
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||
.map(PriceAlertService::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PriceAlertEventResponse> events(String userEmail, int days) {
|
||||
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
|
||||
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
|
||||
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
|
||||
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
|
||||
event.getChangedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
|
||||
if (request == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
|
||||
}
|
||||
PriceAlert alert = request.listingId() != null
|
||||
? buildListingAlert(userEmail, request.listingId())
|
||||
: buildLocationAlert(userEmail, request);
|
||||
alert.setLastValue(measure(alert));
|
||||
alert.setLastCheckedAt(Instant.now());
|
||||
return toResponse(alertRepository.save(alert));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
|
||||
PriceAlert alert = requireOwn(userEmail, id);
|
||||
alert.setNotificationsEnabled(enabled);
|
||||
return toResponse(alert);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String userEmail, Long id) {
|
||||
PriceAlert alert = requireOwn(userEmail, id);
|
||||
eventRepository.deleteByAlertId(alert.getId());
|
||||
alertRepository.delete(alert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
|
||||
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
|
||||
*/
|
||||
@Transactional
|
||||
public int checkAll() {
|
||||
int notified = 0;
|
||||
for (PriceAlert alert : alertRepository.findAll()) {
|
||||
try {
|
||||
if (checkOne(alert)) {
|
||||
notified++;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
return notified;
|
||||
}
|
||||
|
||||
private boolean checkOne(PriceAlert alert) {
|
||||
Long current = measure(alert);
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
Long previous = alert.getLastValue();
|
||||
alert.setLastValue(current);
|
||||
alert.setLastCheckedAt(Instant.now());
|
||||
if (previous == null || previous <= 0 || previous.equals(current)) {
|
||||
return false;
|
||||
}
|
||||
double changePct = ((current - previous) * 100.0) / previous;
|
||||
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PriceAlertEvent event = new PriceAlertEvent();
|
||||
event.setAlertId(alert.getId());
|
||||
event.setUserEmail(alert.getUserEmail());
|
||||
event.setKind(alert.getKind());
|
||||
event.setLabel(alert.getLabel());
|
||||
event.setListingId(alert.getListingId());
|
||||
event.setPreviousValue(previous);
|
||||
event.setCurrentValue(current);
|
||||
eventRepository.save(event);
|
||||
|
||||
if (alert.isNotificationsEnabled()) {
|
||||
notify(alert, previous, current, changePct);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void notify(PriceAlert alert, long previous, long current, double changePct) {
|
||||
boolean down = current < previous;
|
||||
String direction = down ? "spadła" : "wzrosła";
|
||||
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
|
||||
? "listing:" + alert.getListingId()
|
||||
: "priceAlerts";
|
||||
String title = alert.getKind() == PriceAlert.Kind.LISTING
|
||||
? "Cena " + direction + ": " + alert.getLabel()
|
||||
: "Zmiana cen: " + alert.getLabel();
|
||||
String body = alert.getKind() == PriceAlert.Kind.LISTING
|
||||
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
|
||||
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
|
||||
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
|
||||
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
|
||||
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
|
||||
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
|
||||
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
|
||||
title, body, link, dedupeKey);
|
||||
}
|
||||
|
||||
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
|
||||
private Long measure(PriceAlert alert) {
|
||||
if (alert.getKind() == PriceAlert.Kind.LISTING) {
|
||||
return listingRepository.findById(alert.getListingId())
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.map(PropertyListing::getPrice)
|
||||
.map(price -> price.longValue())
|
||||
.orElse(null);
|
||||
}
|
||||
PriceSnapshotService.Average average = snapshotService.currentAverage(
|
||||
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
|
||||
alert.getCity(), "");
|
||||
return average == null ? null : average.avgPricePerM2();
|
||||
}
|
||||
|
||||
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
PriceAlert alert = new PriceAlert();
|
||||
alert.setUserEmail(userEmail);
|
||||
alert.setKind(PriceAlert.Kind.LISTING);
|
||||
alert.setListingId(listingId);
|
||||
alert.setLabel(listing.getTitle());
|
||||
return alert;
|
||||
}
|
||||
|
||||
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
|
||||
if (request.city() == null || request.city().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
|
||||
}
|
||||
String city = request.city().trim();
|
||||
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
|
||||
String market = request.market() == null ? "" : request.market().trim();
|
||||
|
||||
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
|
||||
.filter(item -> city.equalsIgnoreCase(item.getCity()))
|
||||
.filter(item -> item.getPropertyType() == propertyType)
|
||||
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
|
||||
.findFirst()
|
||||
.orElseGet(PriceAlert::new);
|
||||
alert.setUserEmail(userEmail);
|
||||
alert.setKind(PriceAlert.Kind.LOCATION);
|
||||
alert.setCity(city);
|
||||
alert.setRegion(request.region() == null ? null : request.region().trim());
|
||||
alert.setPropertyType(propertyType);
|
||||
alert.setMarket(market);
|
||||
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
|
||||
return alert;
|
||||
}
|
||||
|
||||
private PriceAlert requireOwn(String userEmail, Long id) {
|
||||
return alertRepository.findById(id)
|
||||
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
|
||||
}
|
||||
|
||||
private static String money(long value) {
|
||||
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
|
||||
}
|
||||
|
||||
private static PriceAlertResponse toResponse(PriceAlert alert) {
|
||||
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
|
||||
alert.getCity(), alert.getRegion(),
|
||||
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
|
||||
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
|
||||
alert.isNotificationsEnabled(), alert.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
|
||||
* jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
|
||||
*/
|
||||
class PriceAlertThresholdTest {
|
||||
|
||||
private boolean notifiesLocation(long previous, long current) {
|
||||
double changePct = ((current - previous) * 100.0) / previous;
|
||||
return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresSmallMoveOfTheAverage() {
|
||||
// 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
|
||||
assertThat(notifiesLocation(14_000, 14_200)).isFalse();
|
||||
assertThat(notifiesLocation(14_000, 13_800)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifiesAboutRealMove() {
|
||||
assertThat(notifiesLocation(14_000, 14_280)).isTrue();
|
||||
assertThat(notifiesLocation(14_659, 13_523)).isTrue();
|
||||
}
|
||||
}
|
||||
+276
-535
File diff suppressed because it is too large
Load Diff
@@ -620,3 +620,38 @@ test.describe('Historia cen', () => {
|
||||
await expect(page.locator('main')).toContainText('Kraków');
|
||||
});
|
||||
});
|
||||
|
||||
// Alerty cenowe sa na koncie: zmiany wykrywa serwer, a powiadomienie trafia na liste w serwisie.
|
||||
test.describe('Alerty cenowe', () => {
|
||||
test('alert ogloszenia przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.buy);
|
||||
await page.locator('.real-result-card').first().waitFor();
|
||||
const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href');
|
||||
await goto(page, href!);
|
||||
|
||||
const alertButton = page.locator('.listing-detail-alert');
|
||||
if ((await alertButton.innerText()).includes('aktywny')) {
|
||||
await alertButton.click();
|
||||
await expect(alertButton).toHaveText(/Utwórz alert ceny/);
|
||||
}
|
||||
await alertButton.click();
|
||||
await expect(alertButton).toHaveText(/Alert aktywny/);
|
||||
|
||||
// Symulacja innego urzadzenia: zostaje sam token logowania.
|
||||
await page.evaluate(() => {
|
||||
const token = window.localStorage.getItem('polskalokalnie-auth-token');
|
||||
window.localStorage.clear();
|
||||
if (token) {
|
||||
window.localStorage.setItem('polskalokalnie-auth-token', token);
|
||||
}
|
||||
});
|
||||
await goto(page, ROUTES.accountPriceAlerts);
|
||||
await expect(page.locator('.price-alerts-list-section')).not.toContainText('Nie obserwujesz jeszcze żadnego ogłoszenia');
|
||||
|
||||
// Sprzatanie: alert zdejmujemy tam, gdzie zostal zalozony.
|
||||
await goto(page, href!);
|
||||
await page.locator('.listing-detail-alert').click();
|
||||
await expect(page.locator('.listing-detail-alert')).toHaveText(/Utwórz alert ceny/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user