Zapisane wyszukiwania na koncie zamiast w przegladarce
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Nowa tabela saved_searches i /api/saved-searches (lista, dodanie, usuniecie, jednorazowy import wpisow z localStorage). Front czyta liste z serwera, wiec wyszukiwania sa te same na kazdym urzadzeniu i przezywaja wyczyszczenie danych strony. Bez powiadomien. Skrypt czyszczacy usuwa wpisy skasowanych kont. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwanie uzytkownika ("Zapisz wyszukiwanie" na liscie ofert). Wczesniej
|
||||
* zylo tylko w localStorage przegladarki, wiec ginelo po zmianie urzadzenia albo wyczyszczeniu
|
||||
* danych. Nie wysylamy z niego zadnych powiadomien - to lista do wygodnego wracania do filtrow.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "saved_searches")
|
||||
public class SavedSearch {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
/** "buy" albo "rent" - te same wartosci co w adresach /kupno i /wynajem. */
|
||||
@Column(nullable = false, length = 10)
|
||||
private String mode;
|
||||
|
||||
@Column(length = 120)
|
||||
private String city;
|
||||
|
||||
@Column(name = "property_type", length = 60)
|
||||
private String propertyType;
|
||||
|
||||
@Column(name = "price_min")
|
||||
private Long priceMin;
|
||||
|
||||
@Column(name = "price_max")
|
||||
private Long priceMax;
|
||||
|
||||
@Column(name = "area_min")
|
||||
private Integer areaMin;
|
||||
|
||||
@Column(name = "area_max")
|
||||
private Integer areaMax;
|
||||
|
||||
private Integer rooms;
|
||||
|
||||
@Column(name = "secondary_market", nullable = false)
|
||||
private boolean secondaryMarket;
|
||||
|
||||
@Column(name = "primary_market", nullable = false)
|
||||
private boolean primaryMarket;
|
||||
|
||||
@Column(name = "no_fee_only", nullable = false)
|
||||
private boolean noFeeOnly;
|
||||
|
||||
/** Pozostale filtry ("wiecej filtrow") jako JSON - jest ich wiele i zmieniaja sie z widokiem. */
|
||||
@Column(name = "more_filters", columnDefinition = "text")
|
||||
private String moreFilters;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(String propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public Long getPriceMin() {
|
||||
return priceMin;
|
||||
}
|
||||
|
||||
public void setPriceMin(Long priceMin) {
|
||||
this.priceMin = priceMin;
|
||||
}
|
||||
|
||||
public Long getPriceMax() {
|
||||
return priceMax;
|
||||
}
|
||||
|
||||
public void setPriceMax(Long priceMax) {
|
||||
this.priceMax = priceMax;
|
||||
}
|
||||
|
||||
public Integer getAreaMin() {
|
||||
return areaMin;
|
||||
}
|
||||
|
||||
public void setAreaMin(Integer areaMin) {
|
||||
this.areaMin = areaMin;
|
||||
}
|
||||
|
||||
public Integer getAreaMax() {
|
||||
return areaMax;
|
||||
}
|
||||
|
||||
public void setAreaMax(Integer areaMax) {
|
||||
this.areaMax = areaMax;
|
||||
}
|
||||
|
||||
public Integer getRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
public void setRooms(Integer rooms) {
|
||||
this.rooms = rooms;
|
||||
}
|
||||
|
||||
public boolean isSecondaryMarket() {
|
||||
return secondaryMarket;
|
||||
}
|
||||
|
||||
public void setSecondaryMarket(boolean secondaryMarket) {
|
||||
this.secondaryMarket = secondaryMarket;
|
||||
}
|
||||
|
||||
public boolean isPrimaryMarket() {
|
||||
return primaryMarket;
|
||||
}
|
||||
|
||||
public void setPrimaryMarket(boolean primaryMarket) {
|
||||
this.primaryMarket = primaryMarket;
|
||||
}
|
||||
|
||||
public boolean isNoFeeOnly() {
|
||||
return noFeeOnly;
|
||||
}
|
||||
|
||||
public void setNoFeeOnly(boolean noFeeOnly) {
|
||||
this.noFeeOnly = noFeeOnly;
|
||||
}
|
||||
|
||||
public String getMoreFilters() {
|
||||
return moreFilters;
|
||||
}
|
||||
|
||||
public void setMoreFilters(String moreFilters) {
|
||||
this.moreFilters = moreFilters;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwania zalogowanego uzytkownika. Dostep wymaga logowania (regula
|
||||
* anyRequest().authenticated() w SecurityConfig), a kazda operacja dotyczy wylacznie
|
||||
* wpisow wlasciciela konta. Zadnych powiadomien - to tylko lista zapisanych filtrow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/saved-searches")
|
||||
public class SavedSearchController {
|
||||
|
||||
/** Tyle samo, ile dotad miescil zapis w przegladarce - chroni przed zasypaniem listy. */
|
||||
static final int LIMIT = 60;
|
||||
|
||||
private final SavedSearchRepository repository;
|
||||
|
||||
public SavedSearchController(SavedSearchRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** Kryteria wyszukiwania - dokladnie to, co pokazuje pasek filtrow na liscie ofert. */
|
||||
public record SavedSearchRequest(String mode, String city, String propertyType, Long priceMin, Long priceMax,
|
||||
Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny, boolean pierwotny,
|
||||
boolean noFeeOnly, String more) {
|
||||
}
|
||||
|
||||
public record SavedSearchResponse(Long id, String mode, String city, String propertyType, Long priceMin,
|
||||
Long priceMax, Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny,
|
||||
boolean pierwotny, boolean noFeeOnly, String more, Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<SavedSearchResponse> list(Authentication authentication) {
|
||||
return repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(authentication.getName()).stream()
|
||||
.map(SavedSearchController::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public SavedSearchResponse add(@RequestBody SavedSearchRequest request, Authentication authentication) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak trybu wyszukiwania");
|
||||
}
|
||||
String userEmail = authentication.getName();
|
||||
List<SavedSearch> existing = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
SavedSearch duplicate = existing.stream().filter(saved -> sameCriteria(saved, request)).findFirst().orElse(null);
|
||||
if (duplicate != null) {
|
||||
return toResponse(duplicate);
|
||||
}
|
||||
// Najstarsze wpisy ponad limit usuwamy, zeby zapis zawsze sie udal.
|
||||
existing.stream().skip(LIMIT - 1L).forEach(repository::delete);
|
||||
return toResponse(repository.save(fromRequest(request, userEmail)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeniesienie wyszukiwan zapisanych wczesniej w przegladarce. Dodajemy tylko te,
|
||||
* ktorych uzytkownik nie ma jeszcze na koncie - import mozna powtorzyc bez duplikatow.
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@Transactional
|
||||
public List<SavedSearchResponse> importLocal(@RequestBody List<SavedSearchRequest> requests, Authentication authentication) {
|
||||
String userEmail = authentication.getName();
|
||||
if (requests != null) {
|
||||
for (SavedSearchRequest request : requests) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
List<SavedSearch> current = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
if (current.size() >= LIMIT || current.stream().anyMatch(saved -> sameCriteria(saved, request))) {
|
||||
continue;
|
||||
}
|
||||
repository.save(fromRequest(request, userEmail));
|
||||
}
|
||||
}
|
||||
return list(authentication);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> remove(@PathVariable Long id, Authentication authentication) {
|
||||
repository.findById(id)
|
||||
.filter(saved -> saved.getUserEmail().equalsIgnoreCase(authentication.getName()))
|
||||
.ifPresent(repository::delete);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static SavedSearch fromRequest(SavedSearchRequest request, String userEmail) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
Instant now = Instant.now();
|
||||
saved.setUserEmail(userEmail);
|
||||
saved.setMode(request.mode());
|
||||
saved.setCity(request.city());
|
||||
saved.setPropertyType(request.propertyType());
|
||||
saved.setPriceMin(request.priceMin());
|
||||
saved.setPriceMax(request.priceMax());
|
||||
saved.setAreaMin(request.areaMin());
|
||||
saved.setAreaMax(request.areaMax());
|
||||
saved.setRooms(request.rooms());
|
||||
saved.setSecondaryMarket(request.wtorny());
|
||||
saved.setPrimaryMarket(request.pierwotny());
|
||||
saved.setNoFeeOnly(request.noFeeOnly());
|
||||
saved.setMoreFilters(request.more());
|
||||
saved.setCreatedAt(now);
|
||||
saved.setUpdatedAt(now);
|
||||
return saved;
|
||||
}
|
||||
|
||||
static boolean sameCriteria(SavedSearch saved, SavedSearchRequest request) {
|
||||
return Objects.equals(saved.getMode(), request.mode())
|
||||
&& Objects.equals(trim(saved.getCity()), trim(request.city()))
|
||||
&& Objects.equals(trim(saved.getPropertyType()), trim(request.propertyType()))
|
||||
&& Objects.equals(saved.getPriceMin(), request.priceMin())
|
||||
&& Objects.equals(saved.getPriceMax(), request.priceMax())
|
||||
&& Objects.equals(saved.getAreaMin(), request.areaMin())
|
||||
&& Objects.equals(saved.getAreaMax(), request.areaMax())
|
||||
&& Objects.equals(saved.getRooms(), request.rooms())
|
||||
&& saved.isSecondaryMarket() == request.wtorny()
|
||||
&& saved.isPrimaryMarket() == request.pierwotny()
|
||||
&& saved.isNoFeeOnly() == request.noFeeOnly()
|
||||
&& Objects.equals(trim(saved.getMoreFilters()), trim(request.more()));
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static SavedSearchResponse toResponse(SavedSearch saved) {
|
||||
return new SavedSearchResponse(saved.getId(), saved.getMode(), saved.getCity(), saved.getPropertyType(),
|
||||
saved.getPriceMin(), saved.getPriceMax(), saved.getAreaMin(), saved.getAreaMax(), saved.getRooms(),
|
||||
saved.isSecondaryMarket(), saved.isPrimaryMarket(), saved.isNoFeeOnly(), saved.getMoreFilters(),
|
||||
saved.getCreatedAt(), saved.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {
|
||||
|
||||
List<SavedSearch> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||
|
||||
void deleteByUserEmailIgnoreCase(String userEmail);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import pl.polskalokalnie.search.SavedSearchController.SavedSearchRequest;
|
||||
|
||||
class SavedSearchControllerTest {
|
||||
|
||||
private SavedSearch saved(String city, Long priceMax, String more) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
saved.setMode("buy");
|
||||
saved.setCity(city);
|
||||
saved.setPropertyType("Mieszkanie");
|
||||
saved.setPriceMax(priceMax);
|
||||
saved.setSecondaryMarket(true);
|
||||
saved.setMoreFilters(more);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private SavedSearchRequest request(String city, Long priceMax, String more) {
|
||||
return new SavedSearchRequest("buy", city, "Mieszkanie", null, priceMax, null, null, null, true, false, false, more);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsSameCriteriaAsDuplicate() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{}"))).isTrue();
|
||||
// Spacje i brak wartosci nie robia z wyszukiwania nowego wpisu.
|
||||
assertThat(SavedSearchController.sameCriteria(saved(" Kraków ", 800000L, null), request("Kraków", 800000L, ""))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentFiltersAreSeparateSearches() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 900000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Gdańsk", 800000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{\"balkon\":\"tak\"}"))).isFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user