This commit is contained in:
@@ -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);
|
||||
// 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);
|
||||
// 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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SavedSearch> 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(',', ' ');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ public record UserSettingsRequest(
|
||||
Boolean notifySavedSearches,
|
||||
Boolean notifyPriceAlerts,
|
||||
Boolean notifyMessages,
|
||||
Boolean notifyProductNews,
|
||||
String priceHistoryMarkets,
|
||||
String searchLocations,
|
||||
String searchPropertyType,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+39
-52
@@ -4876,7 +4876,6 @@ function AdminPage() {
|
||||
<article><span>Imię i nazwisko</span><strong>{verificationUser.fullName || '-'}</strong></article>
|
||||
<article><span>Adres e-mail</span><strong>{verificationUser.email}</strong></article>
|
||||
<article><span>Numer telefonu</span><strong>{verificationUser.phone || 'Nie podano'}</strong></article>
|
||||
<article><span>Data urodzenia</span><strong>{verificationUser.birthDate ? verificationUser.birthDate.split('-').reverse().join('.') : 'Nie podano'}</strong></article>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -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,28 +11019,19 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* 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 && (
|
||||
<label>
|
||||
<span>Data urodzenia (opcjonalnie)</span>
|
||||
<input
|
||||
type="date"
|
||||
max={todayIsoDate}
|
||||
value={identity.birthDate}
|
||||
onChange={(event) => setIdentityField('birthDate', event.target.value)}
|
||||
/>
|
||||
<small>Nie pokazujemy jej innym użytkownikom.</small>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>{isCompany ? 'Adres siedziby firmy (opcjonalnie)' : 'Adres zamieszkania (opcjonalnie)'}</span>
|
||||
<span>Adres siedziby firmy (opcjonalnie)</span>
|
||||
<input
|
||||
value={identity.address}
|
||||
placeholder={isCompany ? 'Np. Warszawa, ul. Marszałkowska 1' : 'Np. Łódź, ul. Piotrkowska 12'}
|
||||
placeholder="Np. Warszawa, ul. Marszałkowska 1"
|
||||
onChange={(event) => setIdentityField('address', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>Preferencje kontaktu</span>
|
||||
@@ -11413,56 +11397,60 @@ function AccountSettingsPage({ onSearchPreferencesSaved }: { onSearchPreferences
|
||||
{/* --- Powiadomienia --- */}
|
||||
<section className="settings-card" aria-label="Powiadomienia">
|
||||
<div className="settings-card-head">
|
||||
<h2>Powiadomienia e-mail</h2>
|
||||
<p>Wybierz, o czym chcesz otrzymywać wiadomości.</p>
|
||||
<h2>Powiadomienia</h2>
|
||||
<p>
|
||||
Decydują, co trafia na <Link to={ROUTES.notifications}>listę powiadomień</Link> i pod dzwonek w nagłówku.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wlaczone jest tylko to, co serwis naprawde wysyla. Reszta czeka na zbudowanie
|
||||
funkcji - lepiej powiedziec to wprost, niz pozwolic wlaczyc martwy przelacznik. */}
|
||||
{/* Kazdy przelacznik ma po stronie serwera funkcje, ktora go sprawdza - nie ma tu
|
||||
pozycji "na przyszlosc". Zgody marketingowe to osobna sprawa (art. 7 RODO),
|
||||
wiec stoja nizej, pod wlasnym naglowkiem. */}
|
||||
<div className="settings-list compact">
|
||||
<SettingsToggleRow
|
||||
icon="bell"
|
||||
icon="search"
|
||||
label="Nowe oferty z zapisanych wyszukiwań"
|
||||
hint={SETTINGS_NOT_SENT_YET}
|
||||
checked={false}
|
||||
disabled
|
||||
onChange={() => { /* funkcja jeszcze nie powstala */ }}
|
||||
hint="Gdy pojawi się ogłoszenie pasujące do zapisanego wyszukiwania."
|
||||
checked={settings?.notifySavedSearches ?? true}
|
||||
disabled={isLoading}
|
||||
onChange={(next) => { void patchSettings({ notifySavedSearches: next }); }}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
icon="trend"
|
||||
label="Alerty cenowe"
|
||||
hint="Alerty pokazujemy na liście powiadomień - maili z nich nie wysyłamy."
|
||||
checked={false}
|
||||
disabled
|
||||
onChange={() => { /* funkcja jeszcze nie powstala */ }}
|
||||
hint="Zmiany cen z obserwowanych ogłoszeń i lokalizacji."
|
||||
checked={settings?.notifyPriceAlerts ?? true}
|
||||
disabled={isLoading}
|
||||
onChange={(next) => { void patchSettings({ notifyPriceAlerts: next }); }}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
icon="message"
|
||||
label="Wiadomości od użytkowników"
|
||||
label="Wiadomości i terminy oglądania"
|
||||
hint="Wyłączenie nie ukrywa wiadomości - te czekają w zakładce Wiadomości."
|
||||
checked={settings?.notifyMessages ?? true}
|
||||
disabled={isLoading}
|
||||
onChange={(next) => { void patchSettings({ notifyMessages: next }); }}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
icon="gear"
|
||||
label="Nowości i aktualizacje serwisu"
|
||||
hint={SETTINGS_NOT_SENT_YET}
|
||||
checked={false}
|
||||
disabled
|
||||
onChange={() => { /* funkcja jeszcze nie powstala */ }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-card-head settings-card-subhead">
|
||||
<h3>Zgody marketingowe</h3>
|
||||
<p>Dotyczą wiadomości wysyłanych przez nas. Możesz je wycofać w każdej chwili.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-list compact">
|
||||
<SettingsToggleRow
|
||||
icon="mail"
|
||||
label="Oferty promowane i newsletter"
|
||||
hint="Zgoda marketingowa - decyduje o wysyłce kampanii."
|
||||
hint="Wiadomości e-mail o nowościach i promocjach."
|
||||
checked={consent.emailConsent}
|
||||
disabled={isLoading}
|
||||
onChange={(next) => { void updateConsent({ ...consent, emailConsent: next }); }}
|
||||
/>
|
||||
<SettingsToggleRow
|
||||
icon="phone"
|
||||
label="Powiadomienia SMS"
|
||||
hint="Zgoda marketingowa na wiadomości SMS."
|
||||
label="Wiadomości SMS"
|
||||
hint="Ta sama treść, wysyłana SMS-em."
|
||||
checked={consent.smsConsent}
|
||||
disabled={isLoading}
|
||||
onChange={(next) => { void updateConsent({ ...consent, smsConsent: next }); }}
|
||||
@@ -23840,7 +23828,7 @@ function Header({
|
||||
</div>
|
||||
)}
|
||||
{!isLoggedIn && (
|
||||
<Link className="icon-text" to={ROUTES.login}>
|
||||
<Link className="icon-text" to={ROUTES.login} title="Moje konto">
|
||||
<Icon name="user" />
|
||||
<span>Moje konto</span>
|
||||
</Link>
|
||||
@@ -25286,7 +25274,6 @@ function MessagesPage({
|
||||
const updated = await updateProfile({
|
||||
fullName: user.fullName,
|
||||
phone,
|
||||
birthDate: user.birthDate ?? undefined,
|
||||
address: user.address ?? undefined,
|
||||
contactPreference: user.contactPreference,
|
||||
preferredLanguage: user.preferredLanguage,
|
||||
|
||||
@@ -36,7 +36,6 @@ export type UserSettings = {
|
||||
notifySavedSearches: boolean;
|
||||
notifyPriceAlerts: boolean;
|
||||
notifyMessages: boolean;
|
||||
notifyProductNews: boolean;
|
||||
// Lokalizacje sprawdzone w Historii cen (JSON) - historia przegladania, bez powiadomien.
|
||||
priceHistoryMarkets: string | null;
|
||||
searchLocations: string | null;
|
||||
@@ -78,7 +77,6 @@ export type AuthUser = {
|
||||
contactPreference: ContactPreference;
|
||||
preferredLanguage: PreferredLanguage;
|
||||
nip: string | null;
|
||||
birthDate: string | null;
|
||||
verified: boolean;
|
||||
phoneVerified: boolean;
|
||||
blocked: boolean;
|
||||
@@ -132,7 +130,7 @@ export type ProfileUpdate = {
|
||||
fullName: string;
|
||||
nick?: string;
|
||||
phone?: string;
|
||||
birthDate?: string;
|
||||
// Adres siedziby - tylko konta firmowe. Backend odrzuca go dla kont prywatnych.
|
||||
address?: string;
|
||||
// NIP firmy - pomijamy dla kont prywatnych. Pominiete (undefined) = backend nie zmienia NIP-u.
|
||||
nip?: string;
|
||||
|
||||
@@ -10,7 +10,6 @@ const en: Record<string, string> = {
|
||||
'Dane i ustawienia': 'Data and settings',
|
||||
'Edytuj profil': 'Edit profile',
|
||||
'Dodatkowe informacje': 'Additional information',
|
||||
'Adres zamieszkania': 'Residential address',
|
||||
'Preferencje kontaktu': 'Contact preferences',
|
||||
'Język komunikacji': 'Communication language',
|
||||
'Nie podano': 'Not provided',
|
||||
@@ -97,7 +96,6 @@ const uk: Record<string, string> = {
|
||||
'Dane i ustawienia': 'Дані та налаштування',
|
||||
'Edytuj profil': 'Редагувати профіль',
|
||||
'Dodatkowe informacje': 'Додаткова інформація',
|
||||
'Adres zamieszkania': 'Адреса проживання',
|
||||
'Preferencje kontaktu': 'Параметри контакту',
|
||||
'Język komunikacji': 'Мова спілкування',
|
||||
'Nie podano': 'Не вказано',
|
||||
@@ -163,7 +161,6 @@ const de: Record<string, string> = {
|
||||
'Dane i ustawienia': 'Daten und Einstellungen',
|
||||
'Edytuj profil': 'Profil bearbeiten',
|
||||
'Dodatkowe informacje': 'Zusätzliche Informationen',
|
||||
'Adres zamieszkania': 'Wohnadresse',
|
||||
'Preferencje kontaktu': 'Kontaktpräferenzen',
|
||||
'Język komunikacji': 'Kommunikationssprache',
|
||||
'Nie podano': 'Nicht angegeben',
|
||||
|
||||
@@ -20,7 +20,7 @@ const PROVIDER = {
|
||||
};
|
||||
|
||||
const SERVICE_NAME = 'Polska Lokalnie';
|
||||
const LAST_UPDATED = '23 września 2026 r.';
|
||||
const LAST_UPDATED = '25 września 2026 r.';
|
||||
|
||||
type LegalSection = {
|
||||
id: string;
|
||||
@@ -135,7 +135,7 @@ export function TermsPage() {
|
||||
<ol>
|
||||
<li><strong>Bezpłatne ogłoszenia</strong> - dodawanie, edycja i wstrzymywanie ofert sprzedaży oraz najmu, bez prowizji od transakcji.</li>
|
||||
<li><strong>Wyszukiwanie i mapa</strong> - przeglądanie ofert z filtrami, przeglądanie ich na mapie oraz zawężanie wyników do widocznego fragmentu mapy.</li>
|
||||
<li><strong>Konto i narzędzia osobiste</strong> - ulubione ogłoszenia, zapisane wyszukiwania, porównywarka ofert oraz panel z własnymi ogłoszeniami.</li>
|
||||
<li><strong>Konto i narzędzia osobiste</strong> - ulubione ogłoszenia, zapisane wyszukiwania, porównywarka ofert oraz panel z własnymi ogłoszeniami. Gdy pojawi się Ogłoszenie pasujące do zapisanego wyszukiwania, informacja trafia na listę powiadomień w Platformie; Użytkownik może ją wyłączyć w ustawieniach konta.</li>
|
||||
<li><strong>Alerty cenowe</strong> - obserwowanie ceny wybranego Ogłoszenia lub średniej ceny za m² w mieście. Zmiany Platforma sprawdza raz na dobę, a informacja o nich trafia <strong>wyłącznie na listę powiadomień w Platformie</strong>. Z alertów nie są wysyłane wiadomości e-mail ani SMS.</li>
|
||||
<li><strong>Wiadomości</strong> - kontakt między Użytkownikami w sprawie konkretnego Ogłoszenia, z możliwością zablokowania rozmówcy oraz kontakt z obsługą serwisu.</li>
|
||||
<li><strong>Umawianie oglądania</strong> - proponowanie terminu w rozmowie o Ogłoszeniu; druga strona potwierdza go lub odrzuca, a każda ze stron może termin odwołać. Terminy są widoczne w kalendarzu obu stron.</li>
|
||||
@@ -168,10 +168,10 @@ export function TermsPage() {
|
||||
body: (
|
||||
<ol>
|
||||
<li>Rejestracja wymaga podania adresu e-mail, hasła (co najmniej 8 znaków) oraz imienia i nazwiska albo nazwy firmy, a także akceptacji Regulaminu i Polityki prywatności. Moment akceptacji jest zapisywany.</li>
|
||||
<li>Konto zakłada osoba pełnoletnia posiadająca pełną zdolność do czynności prawnych, we własnym imieniu albo z umocowaniem do działania za inny podmiot.</li>
|
||||
<li>Konto zakłada osoba pełnoletnia posiadająca pełną zdolność do czynności prawnych, we własnym imieniu albo z umocowaniem do działania za inny podmiot. Pełnoletność potwierdza oświadczenie Użytkownika złożone przez założenie Konta - Platforma nie zbiera w tym celu daty urodzenia ani nie żąda dokumentu tożsamości.</li>
|
||||
<li>Konto wymaga aktywacji linkiem wysłanym na podany adres e-mail. Do tego czasu logowanie jest zablokowane.</li>
|
||||
<li>Numer telefonu jest dobrowolny. Użytkownik może go potwierdzić kodem SMS; niepotwierdzony numer nie blokuje korzystania z Platformy.</li>
|
||||
<li>Przy rejestracji konta firmowego dane firmy mogą zostać pobrane z rejestru REGON prowadzonego przez Główny Urząd Statystyczny na podstawie podanego numeru NIP.</li>
|
||||
<li>Przy rejestracji konta firmowego dane firmy mogą zostać pobrane z rejestru REGON prowadzonego przez Główny Urząd Statystyczny na podstawie podanego numeru NIP. Adres siedziby jest polem konta firmowego; Użytkownik będący osobą prywatną nie podaje adresu zamieszkania.</li>
|
||||
<li>Logowanie odbywa się wyłącznie adresem e-mail i hasłem. Platforma nie oferuje logowania za pośrednictwem kont w serwisach zewnętrznych.</li>
|
||||
<li>Konto jest przypisane do jednego Użytkownika. Udostępnianie danych logowania osobom trzecim jest niedozwolone.</li>
|
||||
<li>Użytkownik może w każdej chwili usunąć Konto w panelu konta. Skutki usunięcia opisuje <Link to={ROUTES.privacy}>Polityka prywatności</Link>.</li>
|
||||
@@ -416,7 +416,7 @@ export function PrivacyPolicyPage() {
|
||||
title: '2. Jakie dane przetwarzamy',
|
||||
body: (
|
||||
<ul>
|
||||
<li><strong>Dane konta</strong> - adres e-mail, hasło w postaci zaszyfrowanej (funkcja skrótu), imię i nazwisko lub nazwa firmy, nazwa użytkownika, opcjonalnie numer telefonu, adres i NIP.</li>
|
||||
<li><strong>Dane konta</strong> - adres e-mail, hasło w postaci zaszyfrowanej (funkcja skrótu), imię i nazwisko lub nazwa firmy, nazwa użytkownika oraz opcjonalnie numer telefonu. Przy koncie firmowym dodatkowo NIP i adres siedziby. Od osób prywatnych nie zbieramy adresu zamieszkania ani daty urodzenia.</li>
|
||||
<li><strong>Dane profilu</strong> - opis, zdjęcie profilowe i tło, adres strony i profili społecznościowych, preferencje wyszukiwania - jeśli Użytkownik je poda.</li>
|
||||
<li><strong>Treści</strong> - ogłoszenia wraz ze zdjęciami i lokalizacją, artykuły w poradnikach, wiadomości do innych Użytkowników i do obsługi serwisu.</li>
|
||||
<li><strong>Aktywność na koncie</strong> - ulubione ogłoszenia, zapisane wyszukiwania, alerty cenowe, umówione terminy oglądania, powiadomienia.</li>
|
||||
@@ -449,7 +449,8 @@ export function PrivacyPolicyPage() {
|
||||
<ul>
|
||||
<li>Adres e-mail, hasło oraz imię i nazwisko lub nazwa są niezbędne do założenia konta - bez nich nie możemy świadczyć usługi.</li>
|
||||
<li>NIP jest niezbędny przy koncie firmowym.</li>
|
||||
<li>Numer telefonu, adres, opis profilu i zdjęcia są dobrowolne. Ich brak ogranicza jedynie wybrane funkcje (np. kontakt telefoniczny z ogłoszenia).</li>
|
||||
<li>Numer telefonu, adres siedziby firmy, opis profilu i zdjęcia są dobrowolne. Ich brak ogranicza jedynie wybrane funkcje (np. kontakt telefoniczny z ogłoszenia).</li>
|
||||
<li>Zbieramy wyłącznie dane, które są potrzebne do działania konkretnej funkcji serwisu. Pól „na wszelki wypadek" nie ma - jeżeli dane nie mają zastosowania, usuwamy je z formularzy i z bazy.</li>
|
||||
<li>Przeglądanie ogłoszeń nie wymaga zakładania konta.</li>
|
||||
</ul>
|
||||
),
|
||||
|
||||
+32
-26
@@ -129,13 +129,7 @@ svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-admin .icon-text span {
|
||||
color: #dbe4f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header-admin .icon-text.active svg,
|
||||
.header-admin .icon-text.active span {
|
||||
.header-admin .icon-text.active svg {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
@@ -276,18 +270,23 @@ svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon-text span {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
/* Skroty w pasku gornym to same ikony - jeden jezyk dla ulubionych, wiadomosci, dzwonka
|
||||
i konta. Podpis zostaje w znaczniku (czytniki ekranu, dymek title), ale go nie rysujemy.
|
||||
Awatar konta to tez <span>, wiec musi byc wyjety spod tej reguly. */
|
||||
.icon-text span:not(.avatar) {
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.icon-text.active {
|
||||
color: #109457;
|
||||
}
|
||||
|
||||
.icon-text.active svg,
|
||||
.icon-text.active span {
|
||||
.icon-text.active svg {
|
||||
color: #109457;
|
||||
}
|
||||
|
||||
@@ -295,13 +294,7 @@ svg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ponizej 1560 px menu glowne nie miescilo sie miedzy logo a panelem i nachodzilo na oba -
|
||||
"Ulubione" zostaje samym sercem (jak dzwonek i wiadomosci), podpis jest w dymku. */
|
||||
@media (max-width: 1560px) {
|
||||
.favorites-shortcut span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: 20px;
|
||||
}
|
||||
@@ -12411,8 +12404,7 @@ a.city-card:hover {
|
||||
gap: 17px;
|
||||
}
|
||||
|
||||
.nav button, .nav a,
|
||||
.icon-text span {
|
||||
.nav button, .nav a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -12673,12 +12665,6 @@ a.city-card:hover {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Podpisy skrotow ustepuja miejsca, ale awatar konta to tez <span> - musi zostac,
|
||||
inaczej po zalogowaniu w naglowku wisi sama strzalka menu. */
|
||||
.icon-text span:not(.avatar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notification-popover {
|
||||
right: -18px;
|
||||
top: calc(100% + 12px);
|
||||
@@ -20728,6 +20714,26 @@ a.city-card:hover {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Drugi blok w tej samej karcie (zgody marketingowe pod powiadomieniami serwisu) -
|
||||
oddzielony kreska, z mniejszym naglowkiem niz tytul karty. */
|
||||
.settings-card-subhead {
|
||||
border-top: 1px solid #e8edf3;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.settings-card-subhead h3 {
|
||||
color: #1a2c46;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-card-head a {
|
||||
color: #0f8f55;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.settings-card-head.split > button,
|
||||
.settings-profile-row > button, .settings-profile-row > a {
|
||||
background: #ffffff;
|
||||
|
||||
@@ -773,3 +773,83 @@ test.describe('Historia cen', () => {
|
||||
await expect(page.locator('.history-chart-empty')).toContainText('Wybierz lokalizację');
|
||||
});
|
||||
});
|
||||
|
||||
// Minimalizacja danych (art. 5 ust. 1 lit. c RODO): w ustawieniach konta zbieramy tylko to,
|
||||
// czego uzywa jakas funkcja serwisu. Data urodzenia zniknela calkowicie, adres zostal firmom.
|
||||
test.describe('Zakres danych w ustawieniach konta', () => {
|
||||
test('konto prywatne nie ma daty urodzenia ani adresu zamieszkania', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.accountSettings);
|
||||
|
||||
await expect(page.getByText('Data urodzenia')).toHaveCount(0);
|
||||
await expect(page.getByText('Adres zamieszkania')).toHaveCount(0);
|
||||
await expect(page.getByText('Adres siedziby')).toHaveCount(0);
|
||||
// Pola, ktore maja zastosowanie, zostaja na miejscu.
|
||||
await expect(page.getByText('Numer telefonu').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('backend nie zapisze adresu przyslanego z konta prywatnego', async ({ page }) => {
|
||||
await login(page);
|
||||
const token = await page.evaluate(() => window.localStorage.getItem('polskalokalnie-auth-token'));
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const me = await page.request.get('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }).then((r) => r.json());
|
||||
expect(me.accountType).toBe('PERSONAL');
|
||||
|
||||
const updated = await page.request.put('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
fullName: me.fullName,
|
||||
phone: me.phone ?? undefined,
|
||||
address: 'ul. Podrzucona 1, Warszawa',
|
||||
contactPreference: me.contactPreference,
|
||||
preferredLanguage: me.preferredLanguage,
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
|
||||
expect(updated.address).toBeNull();
|
||||
// Data urodzenia nie jest juz czescia profilu w API.
|
||||
expect(updated.birthDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Przelaczniki powiadomien maja realnie sterowac lista na /powiadomienia - nie byc ozdoba.
|
||||
test.describe('Ustawienia powiadomien', () => {
|
||||
test('w karcie nie ma martwych przelacznikow', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.accountSettings);
|
||||
|
||||
const card = page.locator('section[aria-label="Powiadomienia"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).not.toContainText('Wkrótce');
|
||||
// Kazdy przelacznik w karcie da sie klikac.
|
||||
const switches = card.getByRole('switch');
|
||||
await expect(switches).toHaveCount(5);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(switches.nth(index)).toBeEnabled();
|
||||
}
|
||||
// Naglowek prowadzi na liste powiadomien.
|
||||
await card.getByRole('link', { name: 'listę powiadomień' }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`${ROUTES.notifications}$`));
|
||||
});
|
||||
|
||||
test('wylaczenie zapisanych wyszukiwan przezywa odswiezenie', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.accountSettings);
|
||||
|
||||
const row = page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' });
|
||||
const toggle = row.getByRole('switch');
|
||||
const wasOn = await toggle.getAttribute('aria-checked');
|
||||
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(700);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(1200);
|
||||
await expect(page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' }).getByRole('switch'))
|
||||
.toHaveAttribute('aria-checked', wasOn === 'true' ? 'false' : 'true');
|
||||
|
||||
// Sprzatanie: wracamy do stanu sprzed testu.
|
||||
await page.locator('.toggle-row').filter({ hasText: 'Nowe oferty z zapisanych wyszukiwań' }).getByRole('switch').click();
|
||||
await page.waitForTimeout(700);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Minimalizacja danych w kontach uzytkownikow (art. 5 ust. 1 lit. c RODO).
|
||||
--
|
||||
-- 2026-09-25 z ustawien konta znikly dwa pola, ktore nie mialy celu:
|
||||
--
|
||||
-- * data urodzenia - zbierana od osob prywatnych i nieuzywana przez zadna funkcje serwisu
|
||||
-- (brak weryfikacji wieku, brak wymogu prawnego). Kolumna app_users.birth_date zostaje
|
||||
-- w bazie, ale kod juz jej nie czyta ani nie zapisuje - ten skrypt czysci wartosci.
|
||||
--
|
||||
-- * adres zamieszkania osoby prywatnej - do kontaktu sluzy e-mail i telefon, a adres
|
||||
-- nieruchomosci stoi przy ogloszeniu. Adres zostaje wylacznie kontom firmowym, gdzie jest
|
||||
-- siedziba przedsiebiorcy (dana jawna z CEIDG/KRS). Skrypt kasuje adresy kont PERSONAL.
|
||||
--
|
||||
-- Domyslnie PROBA NA SUCHO: wszystko w jednej transakcji, skrypt pokazuje stan przed i po,
|
||||
-- po czym robi ROLLBACK. Zapis nastepuje tylko z -v apply=1.
|
||||
--
|
||||
-- docker exec -i polskalokalnie-db-1 psql -U polskalokalnie -d polskalokalnie < scripts/minimize-user-data.sql
|
||||
-- docker exec -i polskalokalnie-db-1 psql -U polskalokalnie -d polskalokalnie -v apply=1 < scripts/minimize-user-data.sql
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
\if :{?apply}
|
||||
\else
|
||||
\set apply 0
|
||||
\endif
|
||||
|
||||
BEGIN;
|
||||
|
||||
\echo '=== Stan przed ==='
|
||||
SELECT 'konta razem' AS co, count(*) AS ile FROM app_users
|
||||
UNION ALL SELECT 'z data urodzenia', count(*) FROM app_users WHERE birth_date IS NOT NULL
|
||||
UNION ALL SELECT 'konta prywatne z adresem', count(*) FROM app_users WHERE account_type = 'PERSONAL' AND address IS NOT NULL
|
||||
UNION ALL SELECT 'konta firmowe z adresem (zostaja)', count(*) FROM app_users WHERE account_type = 'COMPANY' AND address IS NOT NULL;
|
||||
|
||||
UPDATE app_users SET birth_date = NULL WHERE birth_date IS NOT NULL;
|
||||
UPDATE app_users SET address = NULL WHERE account_type = 'PERSONAL' AND address IS NOT NULL;
|
||||
|
||||
\echo '=== Stan po ==='
|
||||
SELECT 'z data urodzenia' AS co, count(*) AS ile FROM app_users WHERE birth_date IS NOT NULL
|
||||
UNION ALL SELECT 'konta prywatne z adresem', count(*) FROM app_users WHERE account_type = 'PERSONAL' AND address IS NOT NULL
|
||||
UNION ALL SELECT 'konta firmowe z adresem (zostaja)', count(*) FROM app_users WHERE account_type = 'COMPANY' AND address IS NOT NULL;
|
||||
|
||||
\if :apply
|
||||
COMMIT;
|
||||
\echo '>>> ZAPISANO (COMMIT) - daty urodzenia wyczyszczone, adresy kont prywatnych usuniete'
|
||||
\else
|
||||
ROLLBACK;
|
||||
\echo '>>> PROBA NA SUCHO - nic nie zostalo zmienione (ROLLBACK). Zapis: -v apply=1'
|
||||
\endif
|
||||
Reference in New Issue
Block a user