Zapisane wyszukiwania na koncie zamiast w przegladarce
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:
2026-09-23 09:10:20 +02:00
parent 8cd2db38cc
commit e85cc70982
7 changed files with 532 additions and 24 deletions
@@ -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();
}
}