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();
}
}
+105 -22
View File
@@ -384,7 +384,71 @@ const CITY_PRICE_ALERTS_STORAGE_SUFFIX = 'city-price-alert-subscriptions';
const LISTING_PRICE_ALERTS_STORAGE_SUFFIX = 'listing-price-alert-subscriptions';
const CITY_PRICE_ALERT_SNAPSHOTS_STORAGE_SUFFIX = 'city-price-alert-snapshots';
const CITY_PRICE_ALERT_EVENTS_STORAGE_SUFFIX = 'city-price-alert-events';
// Klucz zostaje tylko po to, zeby raz przeniesc na konto wyszukiwania zapisane w przegladarce
// przed przeniesieniem tej funkcji na serwer.
const SAVED_SEARCHES_STORAGE_SUFFIX = 'saved-searches';
// Zapisane wyszukiwanie w postaci z API - id nadaje serwer.
type ApiSavedSearch = {
id: number;
mode: SavedSearchMode;
city: string | null;
propertyType: string | null;
priceMin: number | null;
priceMax: number | null;
areaMin: number | null;
areaMax: number | null;
rooms: number | null;
wtorny: boolean;
pierwotny: boolean;
noFeeOnly: boolean;
more: string | null;
createdAt: string;
updatedAt: string;
};
function fromApiSavedSearch(item: ApiSavedSearch): SavedSearchRecord {
let more: Record<string, string> = {};
try {
more = item.more ? JSON.parse(item.more) as Record<string, string> : {};
} catch {
more = {};
}
return {
id: String(item.id),
mode: item.mode,
city: item.city ?? '',
propertyType: item.propertyType ?? '',
priceMin: item.priceMin,
priceMax: item.priceMax,
areaMin: item.areaMin,
areaMax: item.areaMax,
rooms: item.rooms,
wtorny: item.wtorny,
pierwotny: item.pierwotny,
noFeeOnly: item.noFeeOnly,
more,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
}
function toSavedSearchPayload(search: SavedSearchRecord | SavedSearchDraft) {
return {
mode: search.mode,
city: search.city,
propertyType: search.propertyType,
priceMin: search.priceMin,
priceMax: search.priceMax,
areaMin: search.areaMin,
areaMax: search.areaMax,
rooms: search.rooms,
wtorny: search.wtorny,
pierwotny: search.pierwotny,
noFeeOnly: search.noFeeOnly,
more: JSON.stringify(search.more ?? {}),
};
}
// Lokalizacje, ktorych cene za m2 sprawdzil uzytkownik - najnowsza pierwsza, przechowywane per konto.
const PRICE_HISTORY_MARKETS_SUFFIX = 'price-history-markets';
const PRICE_HISTORY_MARKETS_LIMIT = 5;
@@ -2703,12 +2767,26 @@ function App() {
navigate(ROUTES.buy);
};
// Zapisane wyszukiwania trzyma serwer (/api/saved-searches), wiec sa te same na kazdym
// urzadzeniu. Wpisy zapisane wczesniej w przegladarce przenosimy raz przy pierwszym wejsciu.
useEffect(() => {
if (!user) {
setSavedSearchesState([]);
return;
}
setSavedSearchesState(readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []));
let cancelled = false;
const local = readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []);
const load = local.length > 0
? apiFetch<ApiSavedSearch[]>('/saved-searches/import', { method: 'POST', body: JSON.stringify(local.map(toSavedSearchPayload)) })
.then((items) => {
writeUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []);
return items;
})
: apiFetch<ApiSavedSearch[]>('/saved-searches');
load
.then((items) => { if (!cancelled) { setSavedSearchesState(items.map(fromApiSavedSearch)); } })
.catch(() => { if (!cancelled) { setSavedSearchesState([]); } });
return () => { cancelled = true; };
}, [user]);
useEffect(() => {
@@ -2717,12 +2795,6 @@ function App() {
setListingPriceAlertSubscriptionsState(readUserJsonStorage<ListingPriceAlertSubscription[]>(user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []));
}, [user]);
useEffect(() => {
if (!user) {
return;
}
writeUserJsonStorage(user, SAVED_SEARCHES_STORAGE_SUFFIX, savedSearchesState);
}, [savedSearchesState, user]);
useEffect(() => {
writeUserJsonStorage(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, favoriteListingsState);
@@ -3141,20 +3213,19 @@ function App() {
}
const draft = buildSavedSearchDraft(mode, filters);
setSavedSearchesState((current) => {
const existing = current.find((item) => sameSavedSearchCriteria(item, draft));
const existing = savedSearchesState.find((item) => sameSavedSearchCriteria(item, draft));
if (existing) {
return current.filter((item) => item.id !== existing.id);
removeSavedSearch(existing.id);
return;
}
const nowIso = new Date().toISOString();
const next: SavedSearchRecord = {
id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
createdAt: nowIso,
updatedAt: nowIso,
...draft,
};
return [next, ...current].slice(0, SAVED_SEARCHES_LIMIT);
});
apiFetch<ApiSavedSearch>('/saved-searches', { method: 'POST', body: JSON.stringify(toSavedSearchPayload(draft)) })
.then((saved) => {
const record = fromApiSavedSearch(saved);
setSavedSearchesState((current) => (
current.some((item) => item.id === record.id) ? current : [record, ...current].slice(0, SAVED_SEARCHES_LIMIT)
));
})
.catch(() => { /* brak sieci - lista zostaje bez zmian, uzytkownik moze sprobowac ponownie */ });
};
const openSavedSearch = (savedSearch: SavedSearchRecord) => {
@@ -3164,6 +3235,8 @@ function App() {
const removeSavedSearch = (savedSearchId: string) => {
setSavedSearchesState((current) => current.filter((item) => item.id !== savedSearchId));
apiFetch<void>(`/saved-searches/${encodeURIComponent(savedSearchId)}`, { method: 'DELETE' })
.catch(() => { /* usuniecie na serwerze nie wyszlo - wroci przy nastepnym wejsciu */ });
};
const buySearchSaved = savedSearchesState.some((item) => sameSavedSearchCriteria(item, buildSavedSearchDraft('buy', filters)));
@@ -6417,9 +6490,19 @@ function AccountSidebar() {
return 0;
}
})();
const savedSearchCount = user
? readUserJsonStorage<SavedSearchRecord[]>(user, SAVED_SEARCHES_STORAGE_SUFFIX, []).length
: 0;
// Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
const [savedSearchCount, setSavedSearchCount] = useState(0);
useEffect(() => {
if (!user) {
setSavedSearchCount(0);
return;
}
let cancelled = false;
apiFetch<ApiSavedSearch[]>('/saved-searches')
.then((items) => { if (!cancelled) { setSavedSearchCount(items.length); } })
.catch(() => { if (!cancelled) { setSavedSearchCount(0); } });
return () => { cancelled = true; };
}, [user, location.pathname]);
const favoritesCount = user
? readUserJsonStorage<FavoriteListing[]>(user, FAVORITE_LISTINGS_STORAGE_SUFFIX, []).length
: 0;
+31
View File
@@ -500,3 +500,34 @@ test.describe('Standard: przyciski robia to, co obiecuja', () => {
await expect(page.locator('a.help-contact-channel-action[href^="tel:"]')).toHaveCount(1);
});
});
// Zapisane wyszukiwania trzyma serwer, wiec sa te same w kazdej przegladarce i przezywaja
// wyczyszczenie danych strony. Bez powiadomien - to tylko lista filtrow do wygodnego powrotu.
test.describe('Zapisane wyszukiwania', () => {
test('zapis przezywa wyczyszczenie pamieci przegladarki', async ({ page }) => {
await login(page);
await goto(page, `${ROUTES.buy}?miasto=Gdynia`);
const saveButton = page.getByRole('button', { name: /wyszukiwanie/ }).first();
if ((await saveButton.innerText()).includes('Zapisano')) {
await saveButton.click();
await expect(saveButton).toHaveText(/Zapisz wyszukiwanie/);
}
await saveButton.click();
await expect(saveButton).toHaveText(/Zapisano wyszukiwanie/);
// Symulacja innego urzadzenia: zostaje sam token logowania, reszta pamieci znika.
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.accountSearches);
await expect(page.locator('.account-searches-list, main').first()).toContainText('Gdynia');
// Sprzatanie, zeby kolejne uruchomienie testu zaczynalo od czystej listy.
await page.locator('.account-search-remove').first().click();
await expect(page.locator('main')).not.toContainText('Gdynia');
});
});
+1
View File
@@ -108,6 +108,7 @@ DELETE FROM notifications WHERE lower(user_email) NOT IN (SELECT email FROM k
DELETE FROM auth_tokens WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM phone_otps WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM support_messages WHERE lower(account_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM saved_searches WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM user_settings WHERE user_id NOT IN (SELECT id FROM keep_users);
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);