From cf02f480ed0236671ef8061c277cdf6046b50b33 Mon Sep 17 00:00:00 2001 From: Przemek Zatorski Date: Fri, 25 Sep 2026 10:09:33 +0200 Subject: [PATCH] =?UTF-8?q?g=C3=B3rne=20menu=20-=20wiadomo=C5=9Bci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pl/polskalokalnie/auth/AuthService.java | 27 ++- .../auth/dto/UpdateProfileRequest.java | 2 +- .../polskalokalnie/auth/dto/UserResponse.java | 3 - .../listing/ListingService.java | 16 +- .../meeting/MeetingService.java | 16 +- .../message/ConversationMessageNotifier.java | 18 +- .../pricealert/PriceAlertService.java | 25 ++- .../search/SavedSearchNotifier.java | 170 ++++++++++++++++++ .../java/pl/polskalokalnie/user/AppUser.java | 14 +- .../pl/polskalokalnie/user/UserSettings.java | 12 -- .../user/UserSettingsRequest.java | 1 - .../user/UserSettingsResponse.java | 2 - .../user/UserSettingsService.java | 3 - .../listing/ListingEditModerationTest.java | 4 +- .../search/SavedSearchNotifierTest.java | 120 +++++++++++++ frontend/src/App.tsx | 93 +++++----- frontend/src/auth.tsx | 4 +- frontend/src/i18nOverrides.ts | 3 - frontend/src/legal.tsx | 13 +- frontend/src/styles.css | 58 +++--- frontend/tests/routing.spec.ts | 80 +++++++++ scripts/minimize-user-data.sql | 47 +++++ 22 files changed, 577 insertions(+), 154 deletions(-) create mode 100644 backend/src/main/java/pl/polskalokalnie/search/SavedSearchNotifier.java create mode 100644 backend/src/test/java/pl/polskalokalnie/search/SavedSearchNotifierTest.java create mode 100644 scripts/minimize-user-data.sql diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 776b32d..edeb1c8 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -3,8 +3,6 @@ package pl.polskalokalnie.auth; import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.Base64; import java.util.Locale; @@ -112,7 +110,10 @@ public class AuthService { String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null; user.setPhone(phone); user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null); - user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + // Adres zapisujemy tylko firmom - konto prywatne go nie potrzebuje (minimalizacja danych). + if (user.getAccountType() == AccountType.COMPANY) { + user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + } // Rejestracja jest mozliwa tylko po zaznaczeniu zgody w formularzu - zapisujemy jej moment. if (Boolean.TRUE.equals(request.acceptTerms())) { user.setTermsAcceptedAt(Instant.now()); @@ -307,7 +308,7 @@ public class AuthService { AppUser user = userRepository.findByEmailIgnoreCase(email) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); - textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address()); + textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.address()); user.setFullName(request.fullName().trim()); if (request.nick() != null && !request.nick().isBlank()) { @@ -321,22 +322,20 @@ public class AuthService { user.setPhoneVerified(false); } user.setPhone(phone); - user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + // Adres przyjmujemy wylacznie od kont firmowych (siedziba przedsiebiorcy). Konto prywatne + // nie ma po co podawac adresu zamieszkania, wiec nawet przyslany z zewnatrz zostaje odrzucony + // i kasuje to, co ewentualnie zapisano wczesniej. + if (user.getAccountType() == AccountType.COMPANY) { + user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); + } else { + user.setAddress(null); + } // NIP: null = nie zmieniaj (zapis czesciowy nie moze skasowac NIP-u firmy), pusty = wyczysc. if (request.nip() != null) { user.setNip(request.nip().isBlank() ? null : request.nip().trim()); } user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE); user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL); - if (request.birthDate() != null && !request.birthDate().isBlank()) { - try { - user.setBirthDate(LocalDate.parse(request.birthDate().trim())); - } catch (DateTimeParseException ex) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia"); - } - } else { - user.setBirthDate(null); - } AppUser saved = userRepository.save(user); leadSyncService.syncUser(saved); diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java index 08da851..76cc8b2 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UpdateProfileRequest.java @@ -10,7 +10,7 @@ public record UpdateProfileRequest( // Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje. String nick, String phone, - String birthDate, + // Adres siedziby - przyjmowany tylko od kont firmowych. String address, // NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u); // pusty ciag = wyczysc. Dotyczy kont firmowych. diff --git a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java index 4603198..590dac7 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/dto/UserResponse.java @@ -1,7 +1,6 @@ package pl.polskalokalnie.auth.dto; import java.time.Instant; -import java.time.LocalDate; import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AuthProvider; @@ -22,7 +21,6 @@ public record UserResponse( ContactPreference contactPreference, PreferredLanguage preferredLanguage, String nip, - LocalDate birthDate, boolean verified, boolean phoneVerified, boolean blocked, @@ -50,7 +48,6 @@ public record UserResponse( user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE, user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL, user.getNip(), - user.getBirthDate(), user.isVerified(), user.isPhoneVerified(), user.isBlocked(), diff --git a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java index 78eceb0..a83062e 100644 --- a/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java +++ b/backend/src/main/java/pl/polskalokalnie/listing/ListingService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.search.SavedSearchNotifier; import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.UserRepository; @@ -28,15 +29,17 @@ public class ListingService { private final NotificationService notificationService; private final UserRepository userRepository; private final UserSettingsService userSettingsService; + private final SavedSearchNotifier savedSearchNotifier; public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, NotificationService notificationService, UserRepository userRepository, - UserSettingsService userSettingsService) { + UserSettingsService userSettingsService, SavedSearchNotifier savedSearchNotifier) { this.listingRepository = listingRepository; this.textModerationService = textModerationService; this.notificationService = notificationService; this.userRepository = userRepository; this.userSettingsService = userSettingsService; + this.savedSearchNotifier = savedSearchNotifier; } // Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow. @@ -183,7 +186,11 @@ public class ListingService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic"); } listing.setStatus(status); - return ListingResponse.from(listingRepository.save(listing)); + PropertyListing saved = listingRepository.save(listing); + if (status == ListingStatus.APPROVED) { + savedSearchNotifier.notifyMatching(saved); + } + return ListingResponse.from(saved); } @Transactional @@ -309,6 +316,11 @@ public class ListingService { listing.setStatus(status); PropertyListing saved = listingRepository.save(listing); notifyOwnerStatusChange(saved, status); + if (status == ListingStatus.APPROVED) { + // Ogloszenie wlasnie stalo sie widoczne - szukajacy z pasujacym zapisanym + // wyszukiwaniem dostaja powiadomienie na liscie w serwisie. + savedSearchNotifier.notifyMatching(saved); + } return ListingResponse.from(saved); } diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java index f6fa1ef..31ceca0 100644 --- a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java +++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java @@ -181,10 +181,7 @@ public class MeetingService { } private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) { - boolean wantsEmail = userSettingsRepository.findByUserId(recipient.getId()) - .map(UserSettings::isNotifyMessages) - .orElse(true); - if (!wantsEmail) { + if (!wantsNotifications(recipient)) { return; } accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other), @@ -192,10 +189,21 @@ public class MeetingService { } private void notify(AppUser recipient, String title, String body, Long conversationId) { + // Terminy oglądania ustala się w rozmowie, więc podlegają temu samemu przełącznikowi + // co wiadomości. Sam termin zostaje w kalendarzu na /konto/spotkania. + if (!wantsNotifications(recipient)) { + return; + } notificationService.create(recipient.getEmail(), "messages", "calendar", title, body, conversationId == null ? "meetings" : "messages:conversation:" + conversationId, null); } + private boolean wantsNotifications(AppUser recipient) { + return userSettingsRepository.findByUserId(recipient.getId()) + .map(UserSettings::isNotifyMessages) + .orElse(true); + } + private String when(Meeting meeting) { return meeting.getMeetingDate().format(DATE) + ", " + meeting.getStartTime().format(TIME) + "-" + meeting.getEndTime().format(TIME); diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java index 16676af..9f5b5f6 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java @@ -48,19 +48,23 @@ public class ConversationMessageNotifier { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onMessageSent(MessageSentEvent event) { try { + // Przelacznik "Wiadomości od użytkowników" w ustawieniach konta wycisza oba kanaly: + // powiadomienie w serwisie i e-mail. Sama wiadomosc i tak czeka w /wiadomosci. + boolean wantsNotification = userSettingsRepository.findByUserId(event.recipientId()) + .map(UserSettings::isNotifyMessages) + .orElse(true); + if (!wantsNotification) { + return; + } + String preview = event.content().length() > 90 ? event.content().substring(0, 90) + "…" : event.content(); notificationService.create(event.recipientEmail(), "messages", "message", "Nowa wiadomość: " + event.listingTitle(), event.senderName() + ": " + preview, "messages:conversation:" + event.conversationId(), null); - boolean wantsEmail = userSettingsRepository.findByUserId(event.recipientId()) - .map(UserSettings::isNotifyMessages) - .orElse(true); - if (wantsEmail) { - accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(), - event.listingTitle(), event.content(), event.conversationId()); - } + accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(), + event.listingTitle(), event.content(), event.conversationId()); } catch (RuntimeException ex) { log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage()); } diff --git a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java index 534fa00..debf1a3 100644 --- a/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java +++ b/backend/src/main/java/pl/polskalokalnie/pricealert/PriceAlertService.java @@ -20,6 +20,9 @@ import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse; import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest; import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse; import pl.polskalokalnie.pricehistory.PriceSnapshotService; +import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettings; +import pl.polskalokalnie.user.UserSettingsRepository; /** * Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach. @@ -45,17 +48,35 @@ public class PriceAlertService { private final ListingRepository listingRepository; private final PriceSnapshotService snapshotService; private final NotificationService notificationService; + private final UserRepository userRepository; + private final UserSettingsRepository userSettingsRepository; public PriceAlertService(PriceAlertRepository alertRepository, PriceAlertEventRepository eventRepository, ListingRepository listingRepository, PriceSnapshotService snapshotService, - NotificationService notificationService) { + NotificationService notificationService, + UserRepository userRepository, + UserSettingsRepository userSettingsRepository) { this.alertRepository = alertRepository; this.eventRepository = eventRepository; this.listingRepository = listingRepository; this.snapshotService = snapshotService; this.notificationService = notificationService; + this.userRepository = userRepository; + this.userSettingsRepository = userSettingsRepository; + } + + /** + * Globalny przelacznik "Alerty cenowe" z ustawien konta. Wylaczony wycisza wszystkie alerty + * naraz; przelacznik przy pojedynczym alercie dziala niezaleznie i wycisza tylko jego. + * Historia zmian zapisuje sie tak czy inaczej - wyciszamy powiadomienie, nie pomiar. + */ + private boolean wantsAlertNotifications(String userEmail) { + return userRepository.findByEmailIgnoreCase(userEmail) + .flatMap(user -> userSettingsRepository.findByUserId(user.getId())) + .map(UserSettings::isNotifyPriceAlerts) + .orElse(true); } @Transactional(readOnly = true) @@ -147,7 +168,7 @@ public class PriceAlertService { event.setCurrentValue(current); eventRepository.save(event); - if (alert.isNotificationsEnabled()) { + if (alert.isNotificationsEnabled() && wantsAlertNotifications(alert.getUserEmail())) { notify(alert, previous, current, changePct); } return true; diff --git a/backend/src/main/java/pl/polskalokalnie/search/SavedSearchNotifier.java b/backend/src/main/java/pl/polskalokalnie/search/SavedSearchNotifier.java new file mode 100644 index 0000000..1d83d98 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/search/SavedSearchNotifier.java @@ -0,0 +1,170 @@ +package pl.polskalokalnie.search; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.listing.PropertyType; +import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettings; +import pl.polskalokalnie.user.UserSettingsRepository; + +/** + * Powiadomienia o nowych ofertach pasujacych do zapisanych wyszukiwan. + * + * Trafiaja wylacznie na liste powiadomien w serwisie (/powiadomienia) - maili z nich nie + * wysylamy. Wywolywane, gdy ogloszenie staje sie widoczne publicznie (moderacja zatwierdza + * albo wlasciciel wznawia wstrzymane ogloszenie). Klucz deduplikacji pilnuje, zeby to samo + * ogloszenie nie wrocilo do tego samego wyszukiwania po kolejnej zmianie statusu. + */ +@Service +public class SavedSearchNotifier { + + private static final Logger log = LoggerFactory.getLogger(SavedSearchNotifier.class); + + private final SavedSearchRepository savedSearchRepository; + private final UserSettingsRepository userSettingsRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + + public SavedSearchNotifier(SavedSearchRepository savedSearchRepository, + UserSettingsRepository userSettingsRepository, UserRepository userRepository, + NotificationService notificationService) { + this.savedSearchRepository = savedSearchRepository; + this.userSettingsRepository = userSettingsRepository; + this.userRepository = userRepository; + this.notificationService = notificationService; + } + + public void notifyMatching(PropertyListing listing) { + if (listing == null || listing.getId() == null) { + return; + } + List searches; + try { + searches = savedSearchRepository.findAll(); + } catch (RuntimeException ex) { + log.warn("Nie udalo sie odczytac zapisanych wyszukiwan: {}", ex.getMessage()); + return; + } + + for (SavedSearch search : searches) { + String email = search.getUserEmail(); + if (email == null || email.equalsIgnoreCase(listing.getOwnerEmail())) { + // Wlasciciel nie dostaje powiadomienia o wlasnym ogloszeniu. + continue; + } + if (!matches(search, listing) || !wantsNotification(email)) { + continue; + } + notificationService.create(email, "listing", "search", + "Nowa oferta z zapisanego wyszukiwania", + listing.getTitle() + " - " + listing.getCity() + ", " + price(listing), + "listing:" + listing.getId(), + "saved-search:" + search.getId() + ":" + listing.getId()); + } + } + + private boolean wantsNotification(String email) { + return userRepository.findByEmailIgnoreCase(email) + .flatMap(user -> userSettingsRepository.findByUserId(user.getId())) + .map(UserSettings::isNotifySavedSearches) + .orElse(true); + } + + /** + * Te same kryteria, ktore filtruja liste ofert. Puste pole znaczy "bez ograniczenia", + * wiec brak wartosci nigdy nie odrzuca ogloszenia. + */ + boolean matches(SavedSearch search, PropertyListing listing) { + OfferType wanted = "rent".equalsIgnoreCase(search.getMode()) ? OfferType.RENT : OfferType.SALE; + if (listing.getOfferType() != wanted) { + return false; + } + if (notBlank(search.getCity()) && !search.getCity().trim().equalsIgnoreCase(listing.getCity())) { + return false; + } + PropertyType type = propertyType(search.getPropertyType()); + if (type != null && listing.getPropertyType() != type) { + return false; + } + // Zapisane wyszukiwanie moze dotyczyc rodzaju nieruchomosci, ktorego serwis jeszcze + // nie obsluguje (np. dzialka) - wtedy zadne ogloszenie do niego nie pasuje. + if (type == null && notBlank(search.getPropertyType()) && !isAnyPropertyType(search.getPropertyType())) { + return false; + } + BigDecimal price = listing.getPrice(); + if (price != null) { + if (search.getPriceMin() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMin())) < 0) { + return false; + } + if (search.getPriceMax() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMax())) > 0) { + return false; + } + } + Double area = listing.getArea(); + if (area != null) { + if (search.getAreaMin() != null && area < search.getAreaMin()) { + return false; + } + if (search.getAreaMax() != null && area > search.getAreaMax()) { + return false; + } + } + if (search.getRooms() != null && listing.getRooms() != null && listing.getRooms() < search.getRooms()) { + return false; + } + return marketMatches(search, listing); + } + + /** Zaznaczenie obu rynkow (albo zadnego) znaczy "bez znaczenia". */ + private boolean marketMatches(SavedSearch search, PropertyListing listing) { + boolean secondary = search.isSecondaryMarket(); + boolean primary = search.isPrimaryMarket(); + if (secondary == primary) { + return true; + } + String market = listing.getMarket(); + if (market == null || market.isBlank()) { + return true; + } + String normalized = market.trim().toLowerCase(Locale.ROOT); + return secondary ? normalized.startsWith("wt") : normalized.startsWith("pierw"); + } + + private PropertyType propertyType(String label) { + if (label == null) { + return null; + } + String normalized = label.trim().toLowerCase(Locale.ROOT); + if (normalized.startsWith("mieszkan")) { + return PropertyType.APARTMENT; + } + if (normalized.startsWith("dom")) { + return PropertyType.HOUSE; + } + return null; + } + + private boolean isAnyPropertyType(String label) { + String normalized = label.trim().toLowerCase(Locale.ROOT); + return normalized.startsWith("dowoln") || normalized.startsWith("wszystk"); + } + + private boolean notBlank(String value) { + return value != null && !value.isBlank(); + } + + private String price(PropertyListing listing) { + BigDecimal price = listing.getPrice(); + if (price == null) { + return "cena do uzgodnienia"; + } + return String.format(Locale.ROOT, "%,d zł", price.longValue()).replace(',', ' '); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java index 4b68d88..c42ce6b 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/AppUser.java +++ b/backend/src/main/java/pl/polskalokalnie/user/AppUser.java @@ -10,7 +10,6 @@ import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import java.time.Instant; -import java.time.LocalDate; import org.hibernate.annotations.ColumnDefault; @Entity @@ -52,6 +51,9 @@ public class AppUser { @Column(length = 30) private String phone; + // Adres dotyczy wylacznie kont firmowych (siedziba przedsiebiorcy - dana jawna z CEIDG/KRS). + // Konto prywatne go nie ma: do kontaktu wystarcza e-mail i telefon, a adres nieruchomosci + // stoi przy ogloszeniu. Minimalizacja danych, art. 5 ust. 1 lit. c RODO. @Column(length = 255) private String address; @@ -68,8 +70,6 @@ public class AppUser { @Column(length = 20) private String nip; - private LocalDate birthDate; - // Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu). @Column(nullable = false) @ColumnDefault("0") @@ -220,14 +220,6 @@ public class AppUser { this.nip = nip; } - public LocalDate getBirthDate() { - return birthDate; - } - - public void setBirthDate(LocalDate birthDate) { - this.birthDate = birthDate; - } - public int getPromotionCredits() { return promotionCredits; } diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java index 06a525f..84e796a 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettings.java @@ -110,10 +110,6 @@ public class UserSettings { @ColumnDefault("true") private boolean notifyMessages = true; - @Column(nullable = false) - @ColumnDefault("false") - private boolean notifyProductNews = false; - /** * Lokalizacje ostatnio sprawdzone w Historii cen (JSON, maks. kilka pozycji). Lista jest * na koncie, a nie w przegladarce, wiec porownanie cen wyglada tak samo na kazdym urzadzeniu. @@ -310,14 +306,6 @@ public class UserSettings { this.notifyMessages = notifyMessages; } - public boolean isNotifyProductNews() { - return notifyProductNews; - } - - public void setNotifyProductNews(boolean notifyProductNews) { - this.notifyProductNews = notifyProductNews; - } - public String getSearchLocations() { return searchLocations; } diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java index e47bec2..84e0aba 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsRequest.java @@ -24,7 +24,6 @@ public record UserSettingsRequest( Boolean notifySavedSearches, Boolean notifyPriceAlerts, Boolean notifyMessages, - Boolean notifyProductNews, String priceHistoryMarkets, String searchLocations, String searchPropertyType, diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java index 5a240fe..cc58db6 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsResponse.java @@ -21,7 +21,6 @@ public record UserSettingsResponse( boolean notifySavedSearches, boolean notifyPriceAlerts, boolean notifyMessages, - boolean notifyProductNews, String priceHistoryMarkets, String searchLocations, String searchPropertyType, @@ -52,7 +51,6 @@ public record UserSettingsResponse( settings.isNotifySavedSearches(), settings.isNotifyPriceAlerts(), settings.isNotifyMessages(), - settings.isNotifyProductNews(), settings.getPriceHistoryMarkets(), settings.getSearchLocations(), settings.getSearchPropertyType(), diff --git a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java index 47c8d6f..927b2ae 100644 --- a/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java +++ b/backend/src/main/java/pl/polskalokalnie/user/UserSettingsService.java @@ -132,9 +132,6 @@ public class UserSettingsService { if (request.notifyMessages() != null) { settings.setNotifyMessages(request.notifyMessages()); } - if (request.notifyProductNews() != null) { - settings.setNotifyProductNews(request.notifyProductNews()); - } if (request.priceHistoryMarkets() != null) { String markets = request.priceHistoryMarkets().trim(); settings.setPriceHistoryMarkets(markets.isEmpty() ? null : markets); diff --git a/backend/src/test/java/pl/polskalokalnie/listing/ListingEditModerationTest.java b/backend/src/test/java/pl/polskalokalnie/listing/ListingEditModerationTest.java index ec996b4..0b93469 100644 --- a/backend/src/test/java/pl/polskalokalnie/listing/ListingEditModerationTest.java +++ b/backend/src/test/java/pl/polskalokalnie/listing/ListingEditModerationTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.search.SavedSearchNotifier; import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserSettingsService; @@ -22,7 +23,8 @@ class ListingEditModerationTest { private final ListingRepository repository = mock(ListingRepository.class); private final ListingService service = new ListingService(repository, mock(TextModerationService.class), - mock(NotificationService.class), mock(UserRepository.class), mock(UserSettingsService.class)); + mock(NotificationService.class), mock(UserRepository.class), mock(UserSettingsService.class), + mock(SavedSearchNotifier.class)); private PropertyListing listing; private ListingCreateRequest request(String title, BigDecimal price, String phone) { diff --git a/backend/src/test/java/pl/polskalokalnie/search/SavedSearchNotifierTest.java b/backend/src/test/java/pl/polskalokalnie/search/SavedSearchNotifierTest.java new file mode 100644 index 0000000..9175282 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/search/SavedSearchNotifierTest.java @@ -0,0 +1,120 @@ +package pl.polskalokalnie.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.listing.PropertyType; +import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettingsRepository; + +/** + * Dopasowanie nowego ogloszenia do zapisanego wyszukiwania. Puste kryterium znaczy + * "bez ograniczenia" - nie moze odrzucac ofert. + */ +class SavedSearchNotifierTest { + + private final SavedSearchNotifier notifier = new SavedSearchNotifier( + mock(SavedSearchRepository.class), mock(UserSettingsRepository.class), + mock(UserRepository.class), mock(NotificationService.class)); + + private PropertyListing listing() { + PropertyListing listing = new PropertyListing(); + listing.setTitle("Mieszkanie 3 pokoje"); + listing.setOfferType(OfferType.SALE); + listing.setPropertyType(PropertyType.APARTMENT); + listing.setCity("Gdańsk"); + listing.setPrice(BigDecimal.valueOf(750_000)); + listing.setArea(62.0); + listing.setRooms(3); + listing.setMarket("Wtórny"); + return listing; + } + + private SavedSearch search() { + SavedSearch search = new SavedSearch(); + search.setMode("buy"); + return search; + } + + @Test + void pusteWyszukiwaniePasujeDoKazdejOfertySprzedazy() { + assertThat(notifier.matches(search(), listing())).isTrue(); + } + + @Test + void wyszukiwanieWynajmuNiePasujeDoSprzedazy() { + SavedSearch search = search(); + search.setMode("rent"); + assertThat(notifier.matches(search, listing())).isFalse(); + } + + @Test + void miastoMusiSieZgadzac() { + SavedSearch search = search(); + search.setCity("Gdańsk"); + assertThat(notifier.matches(search, listing())).isTrue(); + + search.setCity("Kraków"); + assertThat(notifier.matches(search, listing())).isFalse(); + } + + @Test + void cenaPozaWidelkamiOdrzucaOferte() { + SavedSearch search = search(); + search.setPriceMax(700_000L); + assertThat(notifier.matches(search, listing())).isFalse(); + + search.setPriceMax(800_000L); + assertThat(notifier.matches(search, listing())).isTrue(); + + search.setPriceMin(900_000L); + assertThat(notifier.matches(search, listing())).isFalse(); + } + + @Test + void metrazIPokojeDzialajaJakoWartosciMinimalne() { + SavedSearch search = search(); + search.setAreaMin(70); + assertThat(notifier.matches(search, listing())).isFalse(); + + search.setAreaMin(50); + search.setRooms(4); + assertThat(notifier.matches(search, listing())).isFalse(); + + search.setRooms(2); + assertThat(notifier.matches(search, listing())).isTrue(); + } + + @Test + void rynekOgraniczaTylkoWtedyGdyWybranoJeden() { + SavedSearch search = search(); + search.setSecondaryMarket(true); + search.setPrimaryMarket(true); + assertThat(notifier.matches(search, listing())).isTrue(); + + search.setSecondaryMarket(false); + assertThat(notifier.matches(search, listing())).isFalse(); + + search.setSecondaryMarket(true); + search.setPrimaryMarket(false); + assertThat(notifier.matches(search, listing())).isTrue(); + } + + @Test + void rodzajNieruchomosciSpozaOfertySerwisuNigdyNiePasuje() { + SavedSearch search = search(); + search.setPropertyType("Działka"); + assertThat(notifier.matches(search, listing())).isFalse(); + + search.setPropertyType("Mieszkanie"); + assertThat(notifier.matches(search, listing())).isTrue(); + + search.setPropertyType("Dom"); + assertThat(notifier.matches(search, listing())).isFalse(); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 977847f..170865c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4876,7 +4876,6 @@ function AdminPage() {
Imię i nazwisko{verificationUser.fullName || '-'}
Adres e-mail{verificationUser.email}
Numer telefonu{verificationUser.phone || 'Nie podano'}
-
Data urodzenia{verificationUser.birthDate ? verificationUser.birthDate.split('-').reverse().join('.') : 'Nie podano'}
)} @@ -10102,7 +10101,6 @@ const OTP_RESEND_COOLDOWN_SECONDS = 60; // Pojedynczy wiersz z przelacznikiem. Zapis idzie na serwer od razu po kliknieciu. // Powiadomienia, ktorych serwis jeszcze nie wysyla - przelacznik jest nieaktywny i mowi wprost dlaczego. -const SETTINGS_NOT_SENT_YET = 'Wkrótce - tych wiadomości jeszcze nie wysyłamy.'; function SettingsToggleRow({ icon, label, hint, checked, disabled, onChange }: { icon: string; @@ -10164,7 +10162,6 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences nick: '', phonePrefix: DEFAULT_PHONE_COUNTRY.code as string, phoneDigits: '', - birthDate: '', address: '', contactPreference: 'EMAIL_AND_PHONE' as ContactPreference, preferredLanguage: 'PL' as PreferredLanguage, @@ -10276,7 +10273,6 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences const composedPhone = identity.phoneDigits ? `${identity.phonePrefix} ${identity.phoneDigits}` : ''; // OTP idzie na numer zapisany w bazie, wiec potwierdzac mozna dopiero zapisany numer. const isPhoneSaved = composedPhone !== '' && composedPhone === (user?.phone ?? ''); - const todayIsoDate = new Date().toISOString().slice(0, 10); // Formularz wypelniamy danymi z serwera - tymi samymi, ktore uzytkownik podal przy rejestracji. useEffect(() => { @@ -10294,7 +10290,6 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences nick: user.nick ?? '', phonePrefix: phone.prefix, phoneDigits: phone.digits, - birthDate: user.birthDate ?? '', address: user.address ?? '', contactPreference: user.contactPreference, preferredLanguage: user.preferredLanguage, @@ -10476,7 +10471,6 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences fullName: user.fullName, nick: user.nick ?? undefined, phone: user.phone ?? undefined, - birthDate: user.birthDate ?? undefined, address: user.address ?? undefined, contactPreference: user.contactPreference, preferredLanguage: next, @@ -10522,9 +10516,8 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences phone: composedPhone || undefined, // NIP wysylamy tylko dla firmy (dla osoby prywatnej undefined = backend go nie zmienia). nip: isCompany ? identity.nip.trim() : undefined, - // Firma nie ma daty urodzenia - nie wysylamy jej z konta firmowego. - birthDate: isCompany ? undefined : (identity.birthDate || undefined), - address: identity.address.trim() || undefined, + // Adres to siedziba firmy - konto prywatne go nie podaje i backend go nie przyjmie. + address: isCompany ? (identity.address.trim() || undefined) : undefined, contactPreference: identity.contactPreference, preferredLanguage: identity.preferredLanguage, }); @@ -11026,29 +11019,20 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences )} - {/* Data urodzenia dotyczy tylko osob prywatnych - firma jej nie ma. */} - {!isCompany && ( + {/* Adres zbieramy tylko od firm - to siedziba przedsiebiorcy, dana jawna z CEIDG/KRS. + Osoba prywatna nie ma po co podawac adresu zamieszkania: do kontaktu sluzy e-mail + i telefon, a adres nieruchomosci stoi przy ogloszeniu. */} + {isCompany && ( )} - -