Compare commits
20 Commits
036cb59331
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 48dba240ee | |||
| d9819d0488 | |||
| cf02f480ed | |||
| 517e80d6bd | |||
| 09d4b2f32b | |||
| c9c78b6db5 | |||
| ca5f3ebfc1 | |||
| a4457df5f8 | |||
| 008223f311 | |||
| ad1d573e89 | |||
| a53cae7c37 | |||
| b850e4c092 | |||
| 0d149467e9 | |||
| a6c2378cbc | |||
| ac7a3a4efb | |||
| 3a555e3b24 | |||
| 1c1162c93e | |||
| c740612b06 | |||
| d4dd898387 | |||
| b5d121e24e |
@@ -27,3 +27,8 @@ APP_JWT_SECRET=local-dev-secret-change-me-please-32chars-min
|
||||
|
||||
# Adres bazowy w linkach z e-maili (aktywacja konta, reset hasla).
|
||||
APP_PUBLIC_BASE_URL=http://localhost
|
||||
|
||||
# --- Platnosci ---
|
||||
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest potwierdzany automatycznie.
|
||||
# Wylacznie lokalnie (docker-compose.yml). Produkcja tej zmiennej nie czyta.
|
||||
APP_PAYMENTS_AUTO_CONFIRM=true
|
||||
|
||||
+4
-3
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.5</version>
|
||||
<version>3.5.16</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
@@ -64,9 +64,10 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Testy na prawdziwym PostgreSQL (tymczasowy kontener), tak jak produkcja - bez emulacji H2. -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
@@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
|
||||
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
|
||||
import pl.polskalokalnie.support.SupportMessageResponse;
|
||||
import pl.polskalokalnie.support.SupportMessageService;
|
||||
import pl.polskalokalnie.user.AccountErasureService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.BlockedEmail;
|
||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
import pl.polskalokalnie.user.UserSettingsService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@@ -64,6 +66,8 @@ public class AdminController {
|
||||
private final AdminStatsService adminStatsService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final NotificationService notificationService;
|
||||
private final AccountErasureService accountErasureService;
|
||||
private final UserSettingsService userSettingsService;
|
||||
|
||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||
ListingService listingService, ListingReportService listingReportService,
|
||||
@@ -74,7 +78,9 @@ public class AdminController {
|
||||
TextModerationService textModerationService,
|
||||
AdminStatsService adminStatsService,
|
||||
PasswordEncoder passwordEncoder,
|
||||
NotificationService notificationService) {
|
||||
NotificationService notificationService,
|
||||
AccountErasureService accountErasureService,
|
||||
UserSettingsService userSettingsService) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
this.listingService = listingService;
|
||||
@@ -87,6 +93,8 @@ public class AdminController {
|
||||
this.adminStatsService = adminStatsService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.notificationService = notificationService;
|
||||
this.accountErasureService = accountErasureService;
|
||||
this.userSettingsService = userSettingsService;
|
||||
}
|
||||
|
||||
// --- Uzytkownicy ---
|
||||
@@ -166,6 +174,8 @@ public class AdminController {
|
||||
|
||||
String temporaryPassword = generateTemporaryPassword();
|
||||
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
||||
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
|
||||
user.revokeSessions();
|
||||
userRepository.save(user);
|
||||
notificationService.create(user.getEmail(), "system", "lock",
|
||||
"Hasło zostało zresetowane",
|
||||
@@ -181,6 +191,7 @@ public class AdminController {
|
||||
if (user.getRole() == Role.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||
}
|
||||
listingService.deleteAllOfOwner(user.getEmail());
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ public class AccountEmailService {
|
||||
boolean approved, String reason, String path) {
|
||||
String link = baseUrl + (path == null ? "" : path);
|
||||
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
|
||||
String safeTitle = articleTitle == null ? "" : articleTitle;
|
||||
String safeTitle = articleTitle == null ? "" : HtmlUtils.htmlEscape(articleTitle);
|
||||
String body;
|
||||
if (approved) {
|
||||
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
|
||||
@@ -80,7 +80,7 @@ public class AccountEmailService {
|
||||
} else {
|
||||
String reasonHtml = reason == null || reason.isBlank() ? ""
|
||||
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
|
||||
+ reason.trim() + "</p>";
|
||||
+ HtmlUtils.htmlEscape(reason.trim()) + "</p>";
|
||||
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
|
||||
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
|
||||
+ reasonHtml
|
||||
@@ -143,8 +143,10 @@ public class AccountEmailService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String greeting(String name) {
|
||||
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
|
||||
// Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
|
||||
// escapowane - inaczej prawdziwy mail z serwisu niosl by link lub tresc atakujacego.
|
||||
static String greeting(String name) {
|
||||
String who = name == null || name.isBlank() ? "" : " " + HtmlUtils.htmlEscape(name.trim().split("\\s+")[0]);
|
||||
return "<p>Cześć" + who + ",</p>";
|
||||
}
|
||||
|
||||
|
||||
@@ -112,10 +112,10 @@ public class AuthController {
|
||||
|
||||
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
||||
|
||||
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.noContent().build();
|
||||
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -14,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.lead.LeadSyncService;
|
||||
import pl.polskalokalnie.listing.ListingService;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
@@ -21,6 +20,7 @@ import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
import pl.polskalokalnie.user.AccountErasureService;
|
||||
import pl.polskalokalnie.user.AccountType;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
@@ -52,6 +52,8 @@ public class AuthService {
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
private final NickService nickService;
|
||||
private final UserSettingsService userSettingsService;
|
||||
private final AccountErasureService accountErasureService;
|
||||
private final ListingService listingService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
@@ -64,7 +66,9 @@ public class AuthService {
|
||||
AccountEmailService accountEmailService,
|
||||
PhoneVerificationService phoneVerificationService,
|
||||
NickService nickService,
|
||||
UserSettingsService userSettingsService
|
||||
UserSettingsService userSettingsService,
|
||||
AccountErasureService accountErasureService,
|
||||
ListingService listingService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
@@ -77,6 +81,8 @@ public class AuthService {
|
||||
this.phoneVerificationService = phoneVerificationService;
|
||||
this.nickService = nickService;
|
||||
this.userSettingsService = userSettingsService;
|
||||
this.accountErasureService = accountErasureService;
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -104,7 +110,14 @@ 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());
|
||||
}
|
||||
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
|
||||
user.setVerified(false);
|
||||
user.setPhoneVerified(false);
|
||||
@@ -191,6 +204,8 @@ public class AuthService {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
|
||||
user.revokeSessions();
|
||||
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
||||
user.setVerified(true);
|
||||
userRepository.save(user);
|
||||
@@ -210,8 +225,12 @@ public class AuthService {
|
||||
*/
|
||||
public void resendPhoneOtp(String email) {
|
||||
String normalized = normalizeEmail(email);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
|
||||
// zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||
}
|
||||
@@ -289,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()) {
|
||||
@@ -303,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);
|
||||
@@ -330,7 +347,7 @@ public class AuthService {
|
||||
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
@@ -357,6 +374,8 @@ public class AuthService {
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setPasswordChangedAt(Instant.now());
|
||||
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
|
||||
user.revokeSessions();
|
||||
userRepository.save(user);
|
||||
|
||||
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
||||
@@ -366,6 +385,7 @@ public class AuthService {
|
||||
authTokenRepository.save(old);
|
||||
}
|
||||
});
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,7 +449,9 @@ public class AuthService {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
|
||||
}
|
||||
|
||||
accountErasureService.eraseFor(user.getId(), user.getEmail());
|
||||
userSettingsService.deleteForUser(user.getId());
|
||||
listingService.deleteAllOfOwner(user.getEmail());
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Sprzatanie jednorazowych tokenow i kodow SMS. Wczesniej zostawaly w bazie bezterminowo,
|
||||
* mimo ze po wygasnieciu sa bezuzyteczne - a wiersz phone_otps zawiera numer telefonu
|
||||
* i kod w jawnej postaci. Zasada ograniczenia przechowywania (art. 5 ust. 1 lit. e RODO)
|
||||
* wymaga, zeby znikaly, gdy przestaja byc potrzebne.
|
||||
*
|
||||
* <p>Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje
|
||||
* czytelny komunikat zamiast bledu "nie znaleziono".</p>
|
||||
*/
|
||||
@Component
|
||||
public class ExpiredTokenCleaner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleaner.class);
|
||||
|
||||
/** Bufor po wygasnieciu - tyle jeszcze trzymamy zuzyty albo przeterminowany wpis. */
|
||||
private static final int GRACE_DAYS = 7;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ExpiredTokenCleaner(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 15 3 * * *")
|
||||
@Transactional
|
||||
public void cleanDaily() {
|
||||
Instant cutoff = Instant.now().minus(GRACE_DAYS, ChronoUnit.DAYS);
|
||||
try {
|
||||
int tokens = jdbcTemplate.update("DELETE FROM auth_tokens WHERE expires_at < ?", cutoff);
|
||||
int otps = jdbcTemplate.update("DELETE FROM phone_otps WHERE expires_at < ?", cutoff);
|
||||
if (tokens > 0 || otps > 0) {
|
||||
log.info("Sprzatanie: usunieto {} wygaslych tokenow i {} kodow SMS", tokens, otps);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Sprzatanie wygaslych tokenow nie powiodlo sie: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -14,14 +15,20 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
static final String STREAM_PATH = "/api/notifications/stream";
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService) {
|
||||
private final JwtService jwtService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
|
||||
this.jwtService = jwtService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -30,16 +37,29 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String token = resolveToken(request);
|
||||
if (token != null) {
|
||||
String header = request.getHeader("Authorization");
|
||||
boolean fromHeader = header != null && header.startsWith("Bearer ");
|
||||
// Token w adresie tylko dla strumienia powiadomien (EventSource nie ustawia naglowkow) i tylko
|
||||
// krotki token strumienia - token sesji w adresie trafial do logow serwera.
|
||||
String token = fromHeader ? header.substring(7)
|
||||
: STREAM_PATH.equals(request.getRequestURI()) ? request.getParameter("ticket") : null;
|
||||
if (token != null && !token.isBlank()) {
|
||||
try {
|
||||
Claims claims = jwtService.parse(token);
|
||||
String email = claims.getSubject();
|
||||
String role = claims.get("role", String.class);
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
|
||||
boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
|
||||
if (streamToken == fromHeader) {
|
||||
// Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
|
||||
throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
|
||||
}
|
||||
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
|
||||
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
|
||||
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
|
||||
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
|
||||
SecurityContextHolder.clearContext();
|
||||
@@ -48,16 +68,12 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7);
|
||||
static boolean isSessionValid(AppUser user, Instant issuedAt) {
|
||||
if (user.isBlocked() || user.getRole() == null) {
|
||||
return false;
|
||||
}
|
||||
String param = request.getParameter("access_token");
|
||||
if (param != null && !param.isBlank()) {
|
||||
return param;
|
||||
}
|
||||
return null;
|
||||
Instant validFrom = user.getSessionsValidFrom();
|
||||
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,26 @@ public class JwtService {
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** Przeznaczenie tokenu strumienia powiadomien (claim "purpose"). */
|
||||
public static final String PURPOSE_STREAM = "sse";
|
||||
private static final long STREAM_TOKEN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Krotki (60 s) token wylacznie do otwarcia strumienia powiadomien. EventSource nie ustawia
|
||||
* naglowkow, wiec token idzie w adresie - a adresy laduja w logach. Dlatego nie jest to token
|
||||
* sesji: dziala tylko na /api/notifications/stream i wygasa po minucie.
|
||||
*/
|
||||
public String generateStreamToken(String email) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(email)
|
||||
.claim("purpose", PURPOSE_STREAM)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(STREAM_TOKEN_SECONDS)))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
|
||||
@@ -39,18 +39,24 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
|
||||
"/api/auth/forgot-password", "/api/auth/reset-password",
|
||||
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
||||
.requestMatchers("/api/i18n/translate").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
|
||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
|
||||
.requestMatchers("/api/auth/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||
// Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
|
||||
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
|
||||
"/api/listings/favorites").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
||||
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
||||
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
|
||||
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
|
||||
// Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu.
|
||||
.requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||
|
||||
@@ -7,11 +7,13 @@ import pl.polskalokalnie.user.AccountType;
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 6, max = 72) String password,
|
||||
@NotBlank String fullName,
|
||||
@NotBlank @Size(min = 8, max = 72) String password,
|
||||
@NotBlank @Size(max = 120) String fullName,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
String nip,
|
||||
String address
|
||||
String address,
|
||||
/** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */
|
||||
Boolean acceptTerms
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import pl.polskalokalnie.user.ContactPreference;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@NotBlank String fullName,
|
||||
@NotBlank @Size(max = 120) String fullName,
|
||||
// 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(),
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package pl.polskalokalnie.campaign;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Limit;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import pl.polskalokalnie.lead.Lead;
|
||||
import pl.polskalokalnie.lead.LeadRepository;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||
import pl.polskalokalnie.send.EmailSender;
|
||||
import pl.polskalokalnie.send.SendResult;
|
||||
import pl.polskalokalnie.send.SmsSender;
|
||||
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||
|
||||
/**
|
||||
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
|
||||
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
|
||||
*
|
||||
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
|
||||
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
|
||||
*/
|
||||
@Component
|
||||
public class CampaignSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
|
||||
|
||||
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
|
||||
private static final int BATCH_SIZE = 25;
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignRecipientRepository recipientRepository;
|
||||
private final LeadRepository leadRepository;
|
||||
private final MailConfigService mailConfigService;
|
||||
private final EmailSender emailSender;
|
||||
private final SmsSender smsSender;
|
||||
private final UnsubscribeService unsubscribeService;
|
||||
private final String baseUrl;
|
||||
|
||||
public CampaignSender(CampaignRepository campaignRepository,
|
||||
CampaignRecipientRepository recipientRepository,
|
||||
LeadRepository leadRepository,
|
||||
MailConfigService mailConfigService,
|
||||
EmailSender emailSender,
|
||||
SmsSender smsSender,
|
||||
UnsubscribeService unsubscribeService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.recipientRepository = recipientRepository;
|
||||
this.leadRepository = leadRepository;
|
||||
this.mailConfigService = mailConfigService;
|
||||
this.emailSender = emailSender;
|
||||
this.smsSender = smsSender;
|
||||
this.unsubscribeService = unsubscribeService;
|
||||
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
|
||||
@Transactional
|
||||
public void processOutbox() {
|
||||
Instant now = Instant.now();
|
||||
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
|
||||
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
|
||||
.forEach(campaign -> {
|
||||
campaign.setStatus(CampaignStatus.SENDING);
|
||||
campaignRepository.save(campaign);
|
||||
});
|
||||
|
||||
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
|
||||
try {
|
||||
sendBatch(campaign);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendBatch(Campaign campaign) {
|
||||
int remainingToday = remainingToday(campaign);
|
||||
if (remainingToday <= 0) {
|
||||
return;
|
||||
}
|
||||
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
|
||||
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
|
||||
if (batch.isEmpty()) {
|
||||
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||
campaignRepository.save(campaign);
|
||||
return;
|
||||
}
|
||||
|
||||
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
|
||||
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
|
||||
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
|
||||
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
|
||||
if (!senderConfigured(smtp, sms)) {
|
||||
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
|
||||
return;
|
||||
}
|
||||
int sent = 0;
|
||||
|
||||
for (CampaignRecipient recipient : batch) {
|
||||
if (stillOptedOut(recipient)) {
|
||||
recipient.setStatus(RecipientStatus.OPTED_OUT);
|
||||
recipientRepository.save(recipient);
|
||||
continue;
|
||||
}
|
||||
SendResult result = campaign.getChannel() == Channel.EMAIL
|
||||
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
|
||||
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
|
||||
if (result.ok()) {
|
||||
recipient.setStatus(RecipientStatus.SENT);
|
||||
recipient.setSentAt(Instant.now());
|
||||
recipient.setProviderMessageId(result.providerMessageId());
|
||||
recipient.setError(null);
|
||||
sent++;
|
||||
} else {
|
||||
recipient.setStatus(RecipientStatus.FAILED);
|
||||
recipient.setError(trim(result.error()));
|
||||
}
|
||||
recipientRepository.save(recipient);
|
||||
}
|
||||
|
||||
campaign.setSentToday(todaysCount(campaign) + sent);
|
||||
campaign.setSentTodayDate(LocalDate.now());
|
||||
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
|
||||
campaignRepository.save(campaign);
|
||||
return;
|
||||
}
|
||||
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||
campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
|
||||
if (smtp != null) {
|
||||
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
|
||||
}
|
||||
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
|
||||
}
|
||||
|
||||
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
|
||||
private int remainingToday(Campaign campaign) {
|
||||
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
|
||||
return limit - todaysCount(campaign);
|
||||
}
|
||||
|
||||
private int todaysCount(Campaign campaign) {
|
||||
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
|
||||
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
|
||||
}
|
||||
|
||||
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
|
||||
private boolean stillOptedOut(CampaignRecipient recipient) {
|
||||
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
|
||||
if (lead == null) {
|
||||
return false;
|
||||
}
|
||||
return recipient.getChannel() == Channel.EMAIL
|
||||
? !lead.isEmailConsent() || lead.isEmailOptOut()
|
||||
: !lead.isSmsConsent() || lead.isSmsOptOut();
|
||||
}
|
||||
|
||||
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
|
||||
private String unsubscribeLink(CampaignRecipient recipient) {
|
||||
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
|
||||
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
|
||||
}
|
||||
|
||||
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
|
||||
String body = campaign.getBody() == null ? "" : campaign.getBody();
|
||||
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
|
||||
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
|
||||
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||
String footer = link == null ? ""
|
||||
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
|
||||
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
|
||||
+ " informacje marketingowe od serwisu Polska Lokalnie. "
|
||||
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
|
||||
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
|
||||
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
|
||||
+ greeting + body + footer + "</div></body></html>";
|
||||
}
|
||||
|
||||
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
|
||||
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
|
||||
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||
return link == null ? body : body + " Rezygnacja: " + link;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() > 240 ? value.substring(0, 240) : value;
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,8 @@ public class CampaignService {
|
||||
|
||||
/**
|
||||
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
|
||||
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
|
||||
* wysylajacego dodajemy w kolejnej fazie.
|
||||
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
|
||||
* Wysylke z outboxu obsluguje {@link CampaignSender}.
|
||||
*/
|
||||
@Transactional
|
||||
public CampaignResponse schedule(Long id) {
|
||||
|
||||
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
|
||||
String email = adminEmail.trim().toLowerCase();
|
||||
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
|
||||
|
||||
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
|
||||
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
|
||||
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
|
||||
|
||||
if (existing == null) {
|
||||
if (passwordMissing) {
|
||||
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
|
||||
return;
|
||||
}
|
||||
AppUser admin = new AppUser();
|
||||
admin.setEmail(email);
|
||||
admin.setFullName(adminName);
|
||||
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
|
||||
if (!adminResetPassword) {
|
||||
return;
|
||||
}
|
||||
if (passwordMissing) {
|
||||
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
|
||||
return;
|
||||
}
|
||||
|
||||
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
|
||||
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
@Service
|
||||
public class RuntimeTranslationService {
|
||||
|
||||
private static final int MAX_TEXTS_PER_REQUEST = 180;
|
||||
private static final int MAX_TEXT_LENGTH = 400;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public Map<String, String> translateBatch(PreferredLanguage language, List<String> texts) {
|
||||
if (texts == null || texts.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia");
|
||||
}
|
||||
if (texts.size() > MAX_TEXTS_PER_REQUEST) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu");
|
||||
}
|
||||
|
||||
if (language == PreferredLanguage.PL) {
|
||||
Map<String, String> passthrough = new LinkedHashMap<>();
|
||||
texts.forEach((text) -> passthrough.put(text, text));
|
||||
return passthrough;
|
||||
}
|
||||
|
||||
String targetCode = toGoogleLang(language);
|
||||
Map<String, String> results = new LinkedHashMap<>();
|
||||
List<String> toTranslate = new ArrayList<>();
|
||||
|
||||
for (String text : texts) {
|
||||
String safeText = text != null ? text : "";
|
||||
if (safeText.length() > MAX_TEXT_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia");
|
||||
}
|
||||
|
||||
String cacheKey = targetCode + "|" + safeText;
|
||||
String cached = cache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
results.put(safeText, cached);
|
||||
} else {
|
||||
toTranslate.add(safeText);
|
||||
}
|
||||
}
|
||||
|
||||
for (String text : toTranslate) {
|
||||
String translated = translateSingle(text, targetCode);
|
||||
cache.put(targetCode + "|" + text, translated);
|
||||
results.put(text, translated);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private String translateSingle(String source, String targetCode) {
|
||||
if (source.isBlank()) {
|
||||
return source;
|
||||
}
|
||||
|
||||
String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8);
|
||||
String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl="
|
||||
+ targetCode + "&dt=t&q=" + encoded;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "PolskaLokalnie/1.0")
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna");
|
||||
}
|
||||
return parseTranslatedText(response.body(), source);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
||||
} catch (IOException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String parseTranslatedText(String payload, String fallback) throws IOException {
|
||||
JsonNode root = objectMapper.readTree(payload);
|
||||
JsonNode topSegments = root.path(0);
|
||||
if (!topSegments.isArray() || topSegments.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
StringBuilder translated = new StringBuilder();
|
||||
for (JsonNode segment : topSegments) {
|
||||
JsonNode part = segment.path(0);
|
||||
if (part.isTextual()) {
|
||||
translated.append(part.asText());
|
||||
}
|
||||
}
|
||||
String value = translated.toString();
|
||||
return value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private String toGoogleLang(PreferredLanguage language) {
|
||||
return switch (language) {
|
||||
case EN -> "en";
|
||||
case UK -> "uk";
|
||||
case DE -> "de";
|
||||
case PL -> "pl";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record TranslateRequest(
|
||||
@NotNull PreferredLanguage language,
|
||||
@NotEmpty List<String> texts
|
||||
) {
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import java.util.Map;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record TranslateResponse(
|
||||
PreferredLanguage language,
|
||||
Map<String, String> translations
|
||||
) {
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/i18n")
|
||||
public class TranslationController {
|
||||
|
||||
private final RuntimeTranslationService runtimeTranslationService;
|
||||
|
||||
public TranslationController(RuntimeTranslationService runtimeTranslationService) {
|
||||
this.runtimeTranslationService = runtimeTranslationService;
|
||||
}
|
||||
|
||||
@PostMapping("/translate")
|
||||
public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) {
|
||||
List<String> normalizedTexts = request.texts().stream()
|
||||
.map(text -> text == null ? "" : text)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
|
||||
return new TranslateResponse(request.language(), translations);
|
||||
}
|
||||
}
|
||||
@@ -67,13 +67,17 @@ public class Lead {
|
||||
@Column(name = "tag", length = 60)
|
||||
private List<String> tags = new ArrayList<>();
|
||||
|
||||
// Zgoda marketingowa musi byc wyrazna i uprzednia (art. 4 pkt 11 i art. 7 RODO oraz Prawo
|
||||
// komunikacji elektronicznej), wiec domyslnie jej NIE MA. Wlacza ja sam uzytkownik
|
||||
// w ustawieniach konta (MarketingConsentController) albo administrator przy imporcie leada,
|
||||
// majac podstawe zebrana poza serwisem.
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean emailConsent = true;
|
||||
@ColumnDefault("false")
|
||||
private boolean emailConsent = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean smsConsent = true;
|
||||
@ColumnDefault("false")
|
||||
private boolean smsConsent = false;
|
||||
|
||||
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -231,7 +231,11 @@ public class LeadService {
|
||||
lead.setPhone(phone);
|
||||
}
|
||||
}
|
||||
// Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
|
||||
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
|
||||
if (lead.getId() == null || lead.getName() == null) {
|
||||
lead.setName(trimToNull(request.fullName()));
|
||||
}
|
||||
|
||||
List<String> tags = new ArrayList<>(lead.getTags());
|
||||
tags.add(ADVERTISING_TAG);
|
||||
@@ -239,9 +243,12 @@ public class LeadService {
|
||||
lead.setTags(cleanTags(tags));
|
||||
|
||||
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
|
||||
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
|
||||
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
|
||||
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
|
||||
if (lead.getId() == null) {
|
||||
lead.setEmailConsent(consent);
|
||||
lead.setSmsConsent(consent);
|
||||
}
|
||||
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
|
||||
leadRepository.save(lead);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class ListingController {
|
||||
|
||||
private final ListingService listingService;
|
||||
private final ListingViewGuard viewGuard;
|
||||
|
||||
public ListingController(ListingService listingService) {
|
||||
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
|
||||
this.listingService = listingService;
|
||||
this.viewGuard = viewGuard;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,12 @@ public class ListingController {
|
||||
return listingService.search(city, offerType, propertyType, directOnly);
|
||||
}
|
||||
|
||||
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
|
||||
@GetMapping("/{id}/seller-listings")
|
||||
public List<ListingResponse> sellerListings(@PathVariable Long id) {
|
||||
return listingService.sellerListings(id);
|
||||
}
|
||||
|
||||
@GetMapping("/mine")
|
||||
public List<ListingResponse> mine(Authentication authentication) {
|
||||
return listingService.findMine(authentication.getName());
|
||||
@@ -50,12 +59,16 @@ public class ListingController {
|
||||
public ListingDetailResponse getById(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean incrementView,
|
||||
Authentication authentication
|
||||
Authentication authentication,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String viewerEmail = authentication == null ? null : authentication.getName();
|
||||
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
||||
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
||||
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
|
||||
// Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
|
||||
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
|
||||
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
|
||||
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -69,7 +82,7 @@ public class ListingController {
|
||||
.body(response);
|
||||
}
|
||||
|
||||
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
|
||||
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
|
||||
@PutMapping("/{id}")
|
||||
public ListingDetailResponse update(
|
||||
@PathVariable Long id,
|
||||
|
||||
@@ -72,18 +72,22 @@ public record ListingDetailResponse(
|
||||
private static final double METERS_PER_DEGREE = 111_320d;
|
||||
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
||||
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
|
||||
return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
|
||||
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
||||
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
|
||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
||||
* zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
|
||||
* wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
|
||||
*
|
||||
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
|
||||
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
|
||||
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
|
||||
*/
|
||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
||||
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
|
||||
boolean masked) {
|
||||
boolean masked, boolean publicView) {
|
||||
String street = masked ? null : listing.getStreet();
|
||||
String building = masked ? null : listing.getBuilding();
|
||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
|
||||
listing.getOwnership(),
|
||||
listing.getContactName(),
|
||||
listing.getContactPhone(),
|
||||
listing.getContactEmail(),
|
||||
publicView ? null : listing.getContactEmail(),
|
||||
listing.getRentExtra(),
|
||||
listing.getAvailableFrom(),
|
||||
listing.getFurnishing(),
|
||||
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
|
||||
List.copyOf(listing.getAmenities()),
|
||||
List.copyOf(listing.getPhotos()),
|
||||
listing.getVirtualTourUrl(),
|
||||
listing.getOwnerEmail(),
|
||||
publicView ? null : listing.getOwnerEmail(),
|
||||
ownerId,
|
||||
ownerAvatar,
|
||||
ownerAccountType,
|
||||
@@ -147,31 +151,33 @@ public record ListingDetailResponse(
|
||||
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
|
||||
}
|
||||
|
||||
private static Double blurLat(PropertyListing listing) {
|
||||
// Zamiast przesuniecia liczonego z (jawnego) identyfikatora ogloszenia - ktore dalo sie odwrocic
|
||||
// i odzyskac dokladny adres - punkt jest przyciagany do srodka kratki siatki. Z odpowiedzi wiadomo
|
||||
// tylko, w ktorej kratce lezy adres. Kratka ma bok rowny promieniowi kola, wiec najdalszy jej rog
|
||||
// (~0,71 promienia od srodka) zawsze miesci sie w rysowanym obszarze, a punkt nie skacze po mapie.
|
||||
private static final double GRID_CELL_M = APPROXIMATE_RADIUS_M;
|
||||
|
||||
static Double blurLat(PropertyListing listing) {
|
||||
if (listing.getLat() == null) {
|
||||
return null;
|
||||
}
|
||||
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE;
|
||||
return snapToCellCenter(listing.getLat(), GRID_CELL_M / METERS_PER_DEGREE);
|
||||
}
|
||||
|
||||
private static Double blurLng(PropertyListing listing) {
|
||||
static Double blurLng(PropertyListing listing) {
|
||||
if (listing.getLng() == null || listing.getLat() == null) {
|
||||
return null;
|
||||
}
|
||||
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
|
||||
if (parallel == 0) {
|
||||
// Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
|
||||
// dokladnej szerokosci adresu - inaczej sam rozmiar kratki zdradzalby dokladne lat.
|
||||
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(blurLat(listing)));
|
||||
if (parallel <= 0) {
|
||||
return listing.getLng();
|
||||
}
|
||||
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel;
|
||||
return snapToCellCenter(listing.getLng(), GRID_CELL_M / parallel);
|
||||
}
|
||||
|
||||
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia,
|
||||
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
|
||||
private static double offsetAngle(PropertyListing listing) {
|
||||
return Math.toRadians((listing.getId() * 137L) % 360);
|
||||
}
|
||||
|
||||
private static double offsetMeters(PropertyListing listing) {
|
||||
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
|
||||
private static double snapToCellCenter(double value, double cellDegrees) {
|
||||
return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ public class ListingFavoriteController {
|
||||
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
|
||||
return listingRepository.findAllById(listingIds).stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
||||
|
||||
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
||||
|
||||
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
|
||||
}
|
||||
|
||||
@@ -30,15 +30,20 @@ public record ListingResponse(
|
||||
Instant promotedUntil,
|
||||
Instant createdAt
|
||||
) {
|
||||
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
|
||||
public static ListingResponse from(PropertyListing listing) {
|
||||
return from(listing, false);
|
||||
return build(listing, false, listing.getOwnerEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
|
||||
* ogladanych spoza panelu wlasciciela i administracji.
|
||||
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
|
||||
* adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
|
||||
*/
|
||||
public static ListingResponse from(PropertyListing listing, boolean masked) {
|
||||
public static ListingResponse publicFrom(PropertyListing listing) {
|
||||
return build(listing, listing.isApproximateLocation(), null);
|
||||
}
|
||||
|
||||
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
|
||||
String district = listing.getDistrict();
|
||||
String address = masked
|
||||
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
|
||||
@@ -60,7 +65,7 @@ public record ListingResponse(
|
||||
listing.getMarket(),
|
||||
listing.getYearBuilt(),
|
||||
listing.getCoverPhoto(),
|
||||
listing.getOwnerEmail(),
|
||||
ownerEmail,
|
||||
listing.getStatus(),
|
||||
listing.getViewsCount(),
|
||||
listing.getPromotedUntil(),
|
||||
|
||||
@@ -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.
|
||||
@@ -52,21 +55,22 @@ public class ListingService {
|
||||
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
|
||||
*/
|
||||
private ListingDetailResponse detailOf(PropertyListing listing) {
|
||||
return detailOf(listing, false);
|
||||
return detailOf(listing, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
|
||||
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
|
||||
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
|
||||
*/
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
|
||||
AppUser owner = ownerOf(listing);
|
||||
if (owner == null) {
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
|
||||
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
|
||||
}
|
||||
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
||||
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
||||
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
|
||||
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +101,7 @@ public class ListingService {
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.filter(listing -> !directOnly || isDirectOffer(listing))
|
||||
.sorted(promotedFirst())
|
||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -134,8 +138,29 @@ public class ListingService {
|
||||
listing = listingRepository.save(listing);
|
||||
}
|
||||
|
||||
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
|
||||
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
|
||||
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
|
||||
// lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
|
||||
boolean publicView = !isOwner && !viewerIsAdmin;
|
||||
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
|
||||
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
|
||||
*/
|
||||
public List<ListingResponse> sellerListings(Long listingId) {
|
||||
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
|
||||
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|
||||
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return listingRepository
|
||||
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
|
||||
.stream()
|
||||
.filter(other -> !other.getId().equals(listingId))
|
||||
.limit(6)
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<ListingResponse> findMine(String ownerEmail) {
|
||||
@@ -161,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
|
||||
@@ -194,8 +223,8 @@ public class ListingService {
|
||||
return detailOf(saved);
|
||||
}
|
||||
|
||||
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
|
||||
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian.
|
||||
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem; istotna zmiana tresci odsyla ogloszenie
|
||||
// do ponownej weryfikacji, a drobne zmiany (cena, metraz, parametry) sa widoczne od razu.
|
||||
@Transactional
|
||||
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
|
||||
PropertyListing listing = listingRepository.findById(id)
|
||||
@@ -206,13 +235,27 @@ public class ListingService {
|
||||
request.title(), request.description(), request.city(), request.address(),
|
||||
request.district(), request.street(), request.contactName());
|
||||
|
||||
List<Object> before = moderatedContent(listing);
|
||||
applyRequest(listing, request);
|
||||
// ownerEmail i status pozostaja bez zmian.
|
||||
// Zmiana tresci widocznej dla kupujacych (tytul, opis, zdjecia, kontakt, link spaceru) wraca do
|
||||
// weryfikacji - inaczej zatwierdzone ogloszenie mozna bylo podmienic na dowolne, np. oszustwo.
|
||||
// Dotyczy tez wstrzymanego, bo wlasciciel sam je wznawia. Cena, metraz itp. zmieniaja sie od razu.
|
||||
boolean visible = listing.getStatus() == ListingStatus.APPROVED || listing.getStatus() == ListingStatus.PAUSED;
|
||||
if (visible && !before.equals(moderatedContent(listing))) {
|
||||
listing.setStatus(ListingStatus.PENDING);
|
||||
}
|
||||
|
||||
PropertyListing saved = listingRepository.save(listing);
|
||||
return detailOf(saved);
|
||||
}
|
||||
|
||||
// Pola, ktorych zmiana wymaga ponownej akceptacji administracji.
|
||||
static List<Object> moderatedContent(PropertyListing listing) {
|
||||
return java.util.Arrays.asList(listing.getTitle(), listing.getDescription(), List.copyOf(listing.getPhotos()),
|
||||
listing.getContactName(), listing.getContactPhone(), listing.getContactEmail(),
|
||||
listing.getVirtualTourUrl());
|
||||
}
|
||||
|
||||
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
|
||||
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
|
||||
listing.setTitle(request.title().trim());
|
||||
@@ -273,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);
|
||||
}
|
||||
|
||||
@@ -306,6 +354,17 @@ public class ListingService {
|
||||
listingRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
|
||||
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteAllOfOwner(String ownerEmail) {
|
||||
if (ownerEmail != null && !ownerEmail.isBlank()) {
|
||||
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
@@ -327,11 +386,20 @@ public class ListingService {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static List<String> limitPhotos(List<String> photos) {
|
||||
// Zdjecia przyjmujemy wylacznie jako data URL obrazu rastrowego. Inny typ (np. text/html,
|
||||
// image/svg+xml) serwowany potem z domeny serwisu pozwalalby uruchomic skrypt u oglądającego.
|
||||
private static final java.util.regex.Pattern PHOTO_DATA_URL =
|
||||
java.util.regex.Pattern.compile("^data:image/(jpeg|png|webp);base64,[A-Za-z0-9+/=\\s]+$");
|
||||
|
||||
static List<String> limitPhotos(List<String> photos) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (photos != null) {
|
||||
for (String photo : photos) {
|
||||
if (photo != null && !photo.isBlank()) {
|
||||
if (!PHOTO_DATA_URL.matcher(photo).matches()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Nieprawidłowy format zdjęcia - dozwolone są obrazy JPEG, PNG i WebP");
|
||||
}
|
||||
result.add(photo);
|
||||
if (result.size() >= MAX_PHOTOS) {
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
|
||||
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
|
||||
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
|
||||
*/
|
||||
@Component
|
||||
public class ListingViewGuard {
|
||||
|
||||
private static final Duration WINDOW = Duration.ofHours(1);
|
||||
private static final int MAX_ENTRIES = 100_000;
|
||||
|
||||
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
|
||||
|
||||
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
|
||||
public boolean shouldCount(Long listingId, String viewer) {
|
||||
Instant now = Instant.now();
|
||||
if (lastCounted.size() > MAX_ENTRIES) {
|
||||
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
|
||||
if (lastCounted.size() > MAX_ENTRIES) {
|
||||
lastCounted.clear();
|
||||
}
|
||||
}
|
||||
String key = listingId + "|" + (viewer == null ? "" : viewer);
|
||||
Instant previous = lastCounted.get(key);
|
||||
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
|
||||
return false;
|
||||
}
|
||||
lastCounted.put(key, now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna,
|
||||
* czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu.
|
||||
*
|
||||
* DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki
|
||||
* lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym
|
||||
* pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen
|
||||
* mieszkan, ktory juz pobieramy w module historii cen.
|
||||
*/
|
||||
@Component
|
||||
public class EurostatRateClient {
|
||||
|
||||
static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m"
|
||||
+ "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6";
|
||||
|
||||
/** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */
|
||||
public record Rate(BigDecimal percent, String period) {
|
||||
}
|
||||
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public Rate latestThreeMonthRate() throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Eurostat odpowiedzial " + response.statusCode());
|
||||
}
|
||||
return parseLatest(mapper.readTree(response.body()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar
|
||||
* czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem
|
||||
* indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time".
|
||||
*/
|
||||
static Rate parseLatest(JsonNode root) throws IOException {
|
||||
JsonNode values = root.path("value");
|
||||
JsonNode periods = root.path("dimension").path("time").path("category").path("index");
|
||||
if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) {
|
||||
throw new IOException("Eurostat: odpowiedz bez danych");
|
||||
}
|
||||
|
||||
// Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste.
|
||||
Map.Entry<String, JsonNode> latest = null;
|
||||
for (var it = periods.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> entry = it.next();
|
||||
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|
||||
|| Comparator.<String>naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
if (latest == null) {
|
||||
throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca");
|
||||
}
|
||||
BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue();
|
||||
if (percent.signum() <= 0) {
|
||||
throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent);
|
||||
}
|
||||
return new Rate(percent, latest.getKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku
|
||||
* pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem
|
||||
* pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "market_indicators")
|
||||
public class MarketIndicator {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "indicator_key", nullable = false, unique = true, length = 40)
|
||||
private String key;
|
||||
|
||||
@Column(nullable = false, precision = 12, scale = 4)
|
||||
private BigDecimal value;
|
||||
|
||||
/** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */
|
||||
@Column(name = "as_of", nullable = false, length = 20)
|
||||
private String asOf;
|
||||
|
||||
/** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */
|
||||
@Column(nullable = false, length = 120)
|
||||
private String source;
|
||||
|
||||
@Column(name = "fetched_at", nullable = false)
|
||||
private Instant fetchedAt;
|
||||
|
||||
protected MarketIndicator() {
|
||||
}
|
||||
|
||||
public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
this.asOf = asOf;
|
||||
this.source = source;
|
||||
this.fetchedAt = fetchedAt;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public BigDecimal getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(BigDecimal value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public String getAsOf() {
|
||||
return asOf;
|
||||
}
|
||||
|
||||
public void setAsOf(String asOf) {
|
||||
this.asOf = asOf;
|
||||
}
|
||||
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public void setSource(String source) {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public Instant getFetchedAt() {
|
||||
return fetchedAt;
|
||||
}
|
||||
|
||||
public void setFetchedAt(Instant fetchedAt) {
|
||||
this.fetchedAt = fetchedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator.
|
||||
* Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/market-indicators")
|
||||
public class MarketIndicatorController {
|
||||
|
||||
public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
|
||||
}
|
||||
|
||||
private final MarketIndicatorService service;
|
||||
|
||||
public MarketIndicatorController(MarketIndicatorService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<IndicatorResponse> list() {
|
||||
return service.all().stream()
|
||||
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
|
||||
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
|
||||
|
||||
Optional<MarketIndicator> findByKey(String key);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import pl.polskalokalnie.pricehistory.GusBdlClient;
|
||||
|
||||
/**
|
||||
* Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny
|
||||
* transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej
|
||||
* jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie.
|
||||
*/
|
||||
@Service
|
||||
public class MarketIndicatorService {
|
||||
|
||||
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */
|
||||
public static final String AVERAGE_WAGE = "average-wage-gross";
|
||||
/** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */
|
||||
public static final String MONEY_MARKET_3M = "money-market-3m";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class);
|
||||
|
||||
private final MarketIndicatorRepository repository;
|
||||
private final GusBdlClient gusClient;
|
||||
private final EurostatRateClient eurostatClient;
|
||||
|
||||
public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient,
|
||||
EurostatRateClient eurostatClient) {
|
||||
this.repository = repository;
|
||||
this.gusClient = gusClient;
|
||||
this.eurostatClient = eurostatClient;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MarketIndicator> all() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void refreshOnStartupIfStale() {
|
||||
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
if (isStale(AVERAGE_WAGE, weekAgo)) {
|
||||
refreshAverageWage();
|
||||
}
|
||||
if (isStale(MONEY_MARKET_3M, weekAgo)) {
|
||||
refreshMoneyMarketRate();
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw")
|
||||
public void refreshWeekly() {
|
||||
refreshAverageWage();
|
||||
refreshMoneyMarketRate();
|
||||
}
|
||||
|
||||
public boolean refreshAverageWage() {
|
||||
try {
|
||||
GusBdlClient.AnnualValue wage = gusClient.latestAverageWage();
|
||||
save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych");
|
||||
log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year());
|
||||
return true;
|
||||
} catch (IOException | RuntimeException ex) {
|
||||
log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc",
|
||||
ex.getMessage());
|
||||
return false;
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean refreshMoneyMarketRate() {
|
||||
try {
|
||||
EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate();
|
||||
save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)");
|
||||
log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period());
|
||||
return true;
|
||||
} catch (IOException | RuntimeException ex) {
|
||||
log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc",
|
||||
ex.getMessage());
|
||||
return false;
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isStale(String key, Instant threshold) {
|
||||
return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected void save(String key, BigDecimal value, String asOf, String source) {
|
||||
Optional<MarketIndicator> existing = repository.findByKey(key);
|
||||
Instant now = Instant.now();
|
||||
if (existing.isPresent()) {
|
||||
MarketIndicator indicator = existing.get();
|
||||
indicator.setValue(value);
|
||||
indicator.setAsOf(asOf);
|
||||
indicator.setSource(source);
|
||||
indicator.setFetchedAt(now);
|
||||
repository.save(indicator);
|
||||
} else {
|
||||
repository.save(new MarketIndicator(key, value, asOf, source, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,8 @@ public class MeetingService {
|
||||
}
|
||||
|
||||
Conversation conversation = requireConversation(me, request.conversationId());
|
||||
// Zablokowany uzytkownik nie moze pisac - propozycja spotkania to tez wiadomosc w rozmowie.
|
||||
conversationService.requireCanWrite(conversation, me);
|
||||
AppUser other = requireUser(conversation.otherParticipant(me.getId()));
|
||||
meeting.setConversationId(conversation.getId());
|
||||
meeting.setListingId(conversation.getListingId());
|
||||
@@ -179,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),
|
||||
@@ -190,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);
|
||||
@@ -212,6 +222,13 @@ public class MeetingService {
|
||||
if (request.date().isBefore(LocalDate.now())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości");
|
||||
}
|
||||
// Limity kolumn w bazie - dluzszy tekst konczyl sie bledem 500 zamiast czytelnym komunikatem.
|
||||
if (request.title().trim().length() > 160
|
||||
|| (request.note() != null && request.note().trim().length() > 200)
|
||||
|| (request.location() != null && request.location().trim().length() > 200)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Za długi tekst - nazwa do 160 znaków, notatka i miejsce do 200 znaków");
|
||||
}
|
||||
}
|
||||
|
||||
private Conversation requireConversation(AppUser me, Long conversationId) {
|
||||
@@ -261,7 +278,11 @@ public class MeetingService {
|
||||
return "Konto usunięte";
|
||||
}
|
||||
String name = user.getFullName();
|
||||
return name == null || name.isBlank() ? user.getEmail() : name;
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name;
|
||||
}
|
||||
// Bez imienia - nick, jak w wiadomosciach. Adres e-mail (login) nie trafia do drugiej strony.
|
||||
return user.getNick() != null ? user.getNick() : "Użytkownik";
|
||||
}
|
||||
|
||||
private static String trimOrNull(String value) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ public class ConversationService {
|
||||
public ConversationDetail start(AppUser me, Long listingId, String content) {
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
|
||||
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
|
||||
if (listing.getStatus() != ListingStatus.APPROVED
|
||||
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
|
||||
}
|
||||
AppUser seller = listing.getOwnerEmail() == null ? null
|
||||
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
|
||||
if (seller == null) {
|
||||
@@ -174,6 +180,8 @@ public class ConversationService {
|
||||
*/
|
||||
@Transactional
|
||||
public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
|
||||
// Notatka niesie tekst od uzytkownika (miejsce spotkania) - podlega tej samej moderacji co wiadomosc.
|
||||
textModerationService.validateOrThrow(content);
|
||||
Message message = new Message();
|
||||
message.setConversationId(conversation.getId());
|
||||
message.setSenderId(sender.getId());
|
||||
@@ -264,6 +272,11 @@ public class ConversationService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/** Blokada miedzy uczestnikami rozmowy obowiazuje tez poza wiadomosciami (np. propozycje spotkan). */
|
||||
public void requireCanWrite(Conversation conversation, AppUser me) {
|
||||
requireNotBlocked(me.getId(), conversation.otherParticipant(me.getId()));
|
||||
}
|
||||
|
||||
private void requireNotBlocked(Long meId, Long otherId) {
|
||||
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
|
||||
|
||||
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import pl.polskalokalnie.auth.JwtService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
public class NotificationController {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
private final JwtService jwtService;
|
||||
|
||||
public NotificationController(NotificationService notificationService) {
|
||||
public NotificationController(NotificationService notificationService, JwtService jwtService) {
|
||||
this.notificationService = notificationService;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -86,6 +89,15 @@ public class NotificationController {
|
||||
return created == null ? null : NotificationResponse.from(created);
|
||||
}
|
||||
|
||||
/** Krotki token do otwarcia strumienia (EventSource przekazuje go w adresie, nie w naglowku). */
|
||||
@PostMapping("/stream-token")
|
||||
public StreamTokenResponse streamToken(Authentication authentication) {
|
||||
return new StreamTokenResponse(jwtService.generateStreamToken(authentication.getName()));
|
||||
}
|
||||
|
||||
public record StreamTokenResponse(String token) {
|
||||
}
|
||||
|
||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter stream(Authentication authentication) {
|
||||
return notificationService.subscribe(authentication.getName());
|
||||
|
||||
@@ -8,7 +8,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -92,17 +91,18 @@ public class OgController {
|
||||
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String cover = listing.getPhotos().get(0);
|
||||
DecodedImage image = decodeDataUrl(cover);
|
||||
DecodedImage image = decodeDataUrl(listing.getPhotos().get(0));
|
||||
if (image == null) {
|
||||
// Zdjecie zapisane jako zwykly URL (nie data URL) - odsylamy crawlera pod ten adres.
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", cover)
|
||||
.build();
|
||||
// Bez przekierowania pod zapisany adres - to byloby otwarte przekierowanie na dowolna strone.
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
// nosniff + sandbox: nawet gdyby w bazie siedzial spreparowany plik, przegladarka nie wykona
|
||||
// go jako strony w domenie serwisu.
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.valueOf(image.mimeType()))
|
||||
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
.body(image.data());
|
||||
}
|
||||
|
||||
@@ -111,9 +111,14 @@ public class OgController {
|
||||
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
|
||||
}
|
||||
|
||||
private record DecodedImage(String mimeType, byte[] data) {}
|
||||
record DecodedImage(String mimeType, byte[] data) {}
|
||||
|
||||
private static DecodedImage decodeDataUrl(String value) {
|
||||
// Tylko obrazy rastrowe - typ MIME pochodzi od uzytkownika i nie moze decydowac o tym,
|
||||
// jako co przegladarka potraktuje odpowiedz (text/html, image/svg+xml = skrypt w domenie serwisu).
|
||||
private static final java.util.Set<String> ALLOWED_IMAGE_TYPES = java.util.Set.of(
|
||||
MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, "image/webp");
|
||||
|
||||
static DecodedImage decodeDataUrl(String value) {
|
||||
if (value == null || !value.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
@@ -129,6 +134,10 @@ public class OgController {
|
||||
if (mime.isBlank()) {
|
||||
mime = MediaType.IMAGE_JPEG_VALUE;
|
||||
}
|
||||
mime = mime.trim().toLowerCase(Locale.ROOT);
|
||||
if (!ALLOWED_IMAGE_TYPES.contains(mime)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
|
||||
return new DecodedImage(mime, data);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
/**
|
||||
* Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64).
|
||||
*
|
||||
* Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD
|
||||
* i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo
|
||||
* wymuszac zadania wychodzace (SSRF).
|
||||
*/
|
||||
public final class AutoPayItnParser {
|
||||
|
||||
private AutoPayItnParser() {
|
||||
}
|
||||
|
||||
/** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */
|
||||
public record ItnTransaction(
|
||||
String orderId,
|
||||
String remoteId,
|
||||
String amount,
|
||||
String currency,
|
||||
String gatewayId,
|
||||
String paymentDate,
|
||||
String paymentStatus,
|
||||
String paymentStatusDetails
|
||||
) {
|
||||
public boolean success() {
|
||||
return "SUCCESS".equalsIgnoreCase(paymentStatus);
|
||||
}
|
||||
|
||||
/** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */
|
||||
List<String> signedValues() {
|
||||
List<String> values = new ArrayList<>();
|
||||
values.add(orderId);
|
||||
values.add(remoteId);
|
||||
values.add(amount);
|
||||
values.add(currency);
|
||||
values.add(gatewayId);
|
||||
values.add(paymentDate);
|
||||
values.add(paymentStatus);
|
||||
values.add(paymentStatusDetails);
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */
|
||||
public record Itn(String serviceId, List<ItnTransaction> transactions, String hash) {
|
||||
|
||||
/** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */
|
||||
public List<String> signedValues() {
|
||||
List<String> values = new ArrayList<>();
|
||||
values.add(serviceId);
|
||||
for (ItnTransaction tx : transactions) {
|
||||
values.addAll(tx.signedValues());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
public static Itn parse(String xml) {
|
||||
Document doc = parseSecurely(xml);
|
||||
Element root = doc.getDocumentElement();
|
||||
|
||||
String serviceId = childText(root, "serviceID");
|
||||
// Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji).
|
||||
String hash = childText(root, "hash");
|
||||
|
||||
List<ItnTransaction> transactions = new ArrayList<>();
|
||||
for (Element tx : elementsByTag(root, "transaction")) {
|
||||
transactions.add(new ItnTransaction(
|
||||
childText(tx, "orderID"),
|
||||
childText(tx, "remoteID"),
|
||||
childText(tx, "amount"),
|
||||
childText(tx, "currency"),
|
||||
childText(tx, "gatewayID"),
|
||||
childText(tx, "paymentDate"),
|
||||
childText(tx, "paymentStatus"),
|
||||
childText(tx, "paymentStatusDetails")));
|
||||
}
|
||||
if (serviceId == null || transactions.isEmpty()) {
|
||||
throw invalid();
|
||||
}
|
||||
return new Itn(serviceId, transactions, hash);
|
||||
}
|
||||
|
||||
private static Document parseSecurely(String xml) {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
return factory.newDocumentBuilder()
|
||||
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (ParserConfigurationException e) {
|
||||
throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e);
|
||||
} catch (Exception e) {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private static ResponseStatusException invalid() {
|
||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
|
||||
}
|
||||
|
||||
/** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */
|
||||
private static String childText(Element parent, String tag) {
|
||||
NodeList children = parent.getChildNodes();
|
||||
for (int i = 0; i < children.getLength(); i++) {
|
||||
Node node = children.item(i);
|
||||
if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) {
|
||||
String text = node.getTextContent();
|
||||
return text == null || text.isBlank() ? null : text.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Element> elementsByTag(Element root, String tag) {
|
||||
List<Element> result = new ArrayList<>();
|
||||
NodeList nodes = root.getElementsByTagName(tag);
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
result.add((Element) nodes.item(i));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,19 @@ package pl.polskalokalnie.payment;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
|
||||
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego
|
||||
* produkcyjnie w Gieldzie Komunalnej.
|
||||
*
|
||||
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
|
||||
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
|
||||
*
|
||||
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
|
||||
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
|
||||
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
|
||||
* parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola
|
||||
* opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100,
|
||||
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
|
||||
*/
|
||||
@Service
|
||||
public class AutoPayService {
|
||||
@@ -26,66 +22,38 @@ public class AutoPayService {
|
||||
private static final String PROD_URL = "https://pay.autopay.eu/payment";
|
||||
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
|
||||
|
||||
/** Adres paywalla z podpisanymi parametrami transakcji. */
|
||||
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||
String description, String customerEmail, String returnUrl) {
|
||||
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
|
||||
// Kolejnosc parametrow ma znaczenie dla podpisu.
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("ServiceID", cfg.getServiceId());
|
||||
params.put("OrderID", orderId);
|
||||
params.put("Amount", amountStr);
|
||||
params.put("Currency", "PLN");
|
||||
params.put("Description", description == null ? "" : description);
|
||||
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
|
||||
if (returnUrl != null && !returnUrl.isBlank()) {
|
||||
params.put("ReturnURL", returnUrl);
|
||||
/**
|
||||
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
|
||||
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
|
||||
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
|
||||
*/
|
||||
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||
String description, String customerEmail) {
|
||||
String amountStr = formatAmount(amount);
|
||||
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
|
||||
customerEmail == null ? "" : customerEmail));
|
||||
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
|
||||
customerEmail, hash);
|
||||
}
|
||||
|
||||
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
params.put("Hash", hash);
|
||||
|
||||
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||
StringBuilder query = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (query.length() > 0) {
|
||||
query.append('&');
|
||||
}
|
||||
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
|
||||
}
|
||||
return base + "?" + query;
|
||||
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
|
||||
public static String formatAmount(BigDecimal amount) {
|
||||
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
||||
return false;
|
||||
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String hash(PaymentConfig cfg, List<String> values) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
continue; // puste pole opcjonalne: pomijamy bez separatora
|
||||
}
|
||||
String provided = params.get("Hash");
|
||||
if (provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
sb.append(value).append(cfg.getHashSeparator());
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (!"Hash".equalsIgnoreCase(e.getKey())) {
|
||||
values.add(e.getValue());
|
||||
}
|
||||
}
|
||||
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
|
||||
return MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
||||
public String computeHash(List<String> values, String separator, String key) {
|
||||
List<String> parts = new ArrayList<>(values);
|
||||
parts.add(key);
|
||||
String data = String.join(separator, parts);
|
||||
sb.append(cfg.getSecretKey());
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
@@ -97,7 +65,16 @@ public class AutoPayService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
||||
/**
|
||||
* Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
|
||||
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
|
||||
*/
|
||||
public static boolean hashMatches(String expected, String actual) {
|
||||
if (expected == null || actual == null) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
|
||||
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.payment;
|
||||
|
||||
/**
|
||||
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
|
||||
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
|
||||
*/
|
||||
public record AutoPayStartForm(
|
||||
String gatewayUrl,
|
||||
String serviceId,
|
||||
String orderId,
|
||||
String amount,
|
||||
String description,
|
||||
String customerEmail,
|
||||
String hash
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
|
||||
/**
|
||||
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
|
||||
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
|
||||
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
|
||||
*
|
||||
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "price_alerts")
|
||||
public class PriceAlert {
|
||||
|
||||
public enum Kind {
|
||||
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
|
||||
LISTING,
|
||||
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
|
||||
LOCATION
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private Kind kind;
|
||||
|
||||
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
|
||||
@Column(nullable = false, length = 200)
|
||||
private String label;
|
||||
|
||||
// --- alert ogloszenia ---
|
||||
|
||||
@Column(name = "listing_id")
|
||||
private Long listingId;
|
||||
|
||||
// --- alert lokalizacji ---
|
||||
|
||||
@Column(length = 120)
|
||||
private String city;
|
||||
|
||||
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
|
||||
@Column(length = 60)
|
||||
private String region;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "property_type", length = 20)
|
||||
private PropertyType propertyType;
|
||||
|
||||
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
|
||||
@Column(length = 20)
|
||||
private String market;
|
||||
|
||||
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
|
||||
@Column(name = "last_value")
|
||||
private Long lastValue;
|
||||
|
||||
@Column(name = "last_checked_at")
|
||||
private Instant lastCheckedAt;
|
||||
|
||||
@Column(name = "notifications_enabled", nullable = false)
|
||||
private boolean notificationsEnabled = true;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public Kind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public PropertyType getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(PropertyType propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public String getMarket() {
|
||||
return market;
|
||||
}
|
||||
|
||||
public void setMarket(String market) {
|
||||
this.market = market;
|
||||
}
|
||||
|
||||
public Long getLastValue() {
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
public void setLastValue(Long lastValue) {
|
||||
this.lastValue = lastValue;
|
||||
}
|
||||
|
||||
public Instant getLastCheckedAt() {
|
||||
return lastCheckedAt;
|
||||
}
|
||||
|
||||
public void setLastCheckedAt(Instant lastCheckedAt) {
|
||||
this.lastCheckedAt = lastCheckedAt;
|
||||
}
|
||||
|
||||
public boolean isNotificationsEnabled() {
|
||||
return notificationsEnabled;
|
||||
}
|
||||
|
||||
public void setNotificationsEnabled(boolean notificationsEnabled) {
|
||||
this.notificationsEnabled = notificationsEnabled;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
|
||||
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
|
||||
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
|
||||
*/
|
||||
@Component
|
||||
public class PriceAlertChecker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
|
||||
|
||||
private final PriceAlertService priceAlertService;
|
||||
|
||||
public PriceAlertChecker(PriceAlertService priceAlertService) {
|
||||
this.priceAlertService = priceAlertService;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 3 * * *")
|
||||
public void checkDaily() {
|
||||
run();
|
||||
}
|
||||
|
||||
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
|
||||
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
|
||||
public void checkOnStartup() {
|
||||
run();
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
int notified = priceAlertService.checkAll();
|
||||
if (notified > 0) {
|
||||
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
|
||||
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
|
||||
|
||||
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
|
||||
@RestController
|
||||
@RequestMapping("/api/price-alerts")
|
||||
public class PriceAlertController {
|
||||
|
||||
private final PriceAlertService priceAlertService;
|
||||
|
||||
public PriceAlertController(PriceAlertService priceAlertService) {
|
||||
this.priceAlertService = priceAlertService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PriceAlertResponse> list(Authentication authentication) {
|
||||
return priceAlertService.list(authentication.getName());
|
||||
}
|
||||
|
||||
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
|
||||
@GetMapping("/events")
|
||||
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
|
||||
return priceAlertService.events(authentication.getName(), days);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
|
||||
return priceAlertService.create(authentication.getName(), request);
|
||||
}
|
||||
|
||||
public record NotificationsRequest(boolean enabled) {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/notifications")
|
||||
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
|
||||
Authentication authentication) {
|
||||
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
|
||||
priceAlertService.delete(authentication.getName(), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class PriceAlertDtos {
|
||||
|
||||
private PriceAlertDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
|
||||
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
|
||||
*/
|
||||
public record PriceAlertRequest(Long listingId, String label, String city, String region,
|
||||
String propertyType, String market) {
|
||||
}
|
||||
|
||||
/**
|
||||
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
|
||||
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
|
||||
*/
|
||||
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
|
||||
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
|
||||
boolean notificationsEnabled, Instant createdAt) {
|
||||
}
|
||||
|
||||
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
|
||||
long previousValue, long currentValue, Instant changedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
|
||||
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "price_alert_events")
|
||||
public class PriceAlertEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "alert_id", nullable = false)
|
||||
private Long alertId;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private PriceAlert.Kind kind;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String label;
|
||||
|
||||
@Column(name = "listing_id")
|
||||
private Long listingId;
|
||||
|
||||
@Column(name = "previous_value", nullable = false)
|
||||
private long previousValue;
|
||||
|
||||
@Column(name = "current_value", nullable = false)
|
||||
private long currentValue;
|
||||
|
||||
@Column(name = "changed_at", nullable = false)
|
||||
private Instant changedAt = Instant.now();
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getAlertId() {
|
||||
return alertId;
|
||||
}
|
||||
|
||||
public void setAlertId(Long alertId) {
|
||||
this.alertId = alertId;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public PriceAlert.Kind getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(PriceAlert.Kind kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public long getPreviousValue() {
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
public void setPreviousValue(long previousValue) {
|
||||
this.previousValue = previousValue;
|
||||
}
|
||||
|
||||
public long getCurrentValue() {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
public void setCurrentValue(long currentValue) {
|
||||
this.currentValue = currentValue;
|
||||
}
|
||||
|
||||
public Instant getChangedAt() {
|
||||
return changedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
|
||||
|
||||
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
|
||||
|
||||
void deleteByAlertId(Long alertId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
|
||||
|
||||
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||
|
||||
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
|
||||
|
||||
List<PriceAlert> findByKind(PriceAlert.Kind kind);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.notification.NotificationService;
|
||||
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.
|
||||
*
|
||||
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
|
||||
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
|
||||
* ruchu pocztowego.</p>
|
||||
*/
|
||||
@Service
|
||||
public class PriceAlertService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
|
||||
|
||||
/**
|
||||
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
|
||||
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
|
||||
* zmiana jest dla obserwujacego istotna.
|
||||
*/
|
||||
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
|
||||
|
||||
private final PriceAlertRepository alertRepository;
|
||||
private final PriceAlertEventRepository eventRepository;
|
||||
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,
|
||||
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)
|
||||
public List<PriceAlertResponse> list(String userEmail) {
|
||||
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||
.map(PriceAlertService::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PriceAlertEventResponse> events(String userEmail, int days) {
|
||||
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
|
||||
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
|
||||
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
|
||||
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
|
||||
event.getChangedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
|
||||
if (request == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
|
||||
}
|
||||
PriceAlert alert = request.listingId() != null
|
||||
? buildListingAlert(userEmail, request.listingId())
|
||||
: buildLocationAlert(userEmail, request);
|
||||
alert.setLastValue(measure(alert));
|
||||
alert.setLastCheckedAt(Instant.now());
|
||||
return toResponse(alertRepository.save(alert));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
|
||||
PriceAlert alert = requireOwn(userEmail, id);
|
||||
alert.setNotificationsEnabled(enabled);
|
||||
return toResponse(alert);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String userEmail, Long id) {
|
||||
PriceAlert alert = requireOwn(userEmail, id);
|
||||
eventRepository.deleteByAlertId(alert.getId());
|
||||
alertRepository.delete(alert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
|
||||
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
|
||||
*/
|
||||
@Transactional
|
||||
public int checkAll() {
|
||||
int notified = 0;
|
||||
for (PriceAlert alert : alertRepository.findAll()) {
|
||||
try {
|
||||
if (checkOne(alert)) {
|
||||
notified++;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
return notified;
|
||||
}
|
||||
|
||||
private boolean checkOne(PriceAlert alert) {
|
||||
Long current = measure(alert);
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
Long previous = alert.getLastValue();
|
||||
alert.setLastValue(current);
|
||||
alert.setLastCheckedAt(Instant.now());
|
||||
if (previous == null || previous <= 0 || previous.equals(current)) {
|
||||
return false;
|
||||
}
|
||||
double changePct = ((current - previous) * 100.0) / previous;
|
||||
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
PriceAlertEvent event = new PriceAlertEvent();
|
||||
event.setAlertId(alert.getId());
|
||||
event.setUserEmail(alert.getUserEmail());
|
||||
event.setKind(alert.getKind());
|
||||
event.setLabel(alert.getLabel());
|
||||
event.setListingId(alert.getListingId());
|
||||
event.setPreviousValue(previous);
|
||||
event.setCurrentValue(current);
|
||||
eventRepository.save(event);
|
||||
|
||||
if (alert.isNotificationsEnabled() && wantsAlertNotifications(alert.getUserEmail())) {
|
||||
notify(alert, previous, current, changePct);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void notify(PriceAlert alert, long previous, long current, double changePct) {
|
||||
boolean down = current < previous;
|
||||
String direction = down ? "spadła" : "wzrosła";
|
||||
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
|
||||
? "listing:" + alert.getListingId()
|
||||
: "priceAlerts";
|
||||
String title = alert.getKind() == PriceAlert.Kind.LISTING
|
||||
? "Cena " + direction + ": " + alert.getLabel()
|
||||
: "Zmiana cen: " + alert.getLabel();
|
||||
String body = alert.getKind() == PriceAlert.Kind.LISTING
|
||||
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
|
||||
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
|
||||
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
|
||||
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
|
||||
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
|
||||
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
|
||||
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
|
||||
title, body, link, dedupeKey);
|
||||
}
|
||||
|
||||
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
|
||||
private Long measure(PriceAlert alert) {
|
||||
if (alert.getKind() == PriceAlert.Kind.LISTING) {
|
||||
return listingRepository.findById(alert.getListingId())
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.map(PropertyListing::getPrice)
|
||||
.map(price -> price.longValue())
|
||||
.orElse(null);
|
||||
}
|
||||
PriceSnapshotService.Average average = snapshotService.currentAverage(
|
||||
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
|
||||
alert.getCity(), "");
|
||||
return average == null ? null : average.avgPricePerM2();
|
||||
}
|
||||
|
||||
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
|
||||
PropertyListing listing = listingRepository.findById(listingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
PriceAlert alert = new PriceAlert();
|
||||
alert.setUserEmail(userEmail);
|
||||
alert.setKind(PriceAlert.Kind.LISTING);
|
||||
alert.setListingId(listingId);
|
||||
alert.setLabel(listing.getTitle());
|
||||
return alert;
|
||||
}
|
||||
|
||||
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
|
||||
if (request.city() == null || request.city().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
|
||||
}
|
||||
String city = request.city().trim();
|
||||
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
|
||||
String market = request.market() == null ? "" : request.market().trim();
|
||||
|
||||
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
|
||||
.filter(item -> city.equalsIgnoreCase(item.getCity()))
|
||||
.filter(item -> item.getPropertyType() == propertyType)
|
||||
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
|
||||
.findFirst()
|
||||
.orElseGet(PriceAlert::new);
|
||||
alert.setUserEmail(userEmail);
|
||||
alert.setKind(PriceAlert.Kind.LOCATION);
|
||||
alert.setCity(city);
|
||||
alert.setRegion(request.region() == null ? null : request.region().trim());
|
||||
alert.setPropertyType(propertyType);
|
||||
alert.setMarket(market);
|
||||
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
|
||||
return alert;
|
||||
}
|
||||
|
||||
private PriceAlert requireOwn(String userEmail, Long id) {
|
||||
return alertRepository.findById(id)
|
||||
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
|
||||
}
|
||||
|
||||
private static String money(long value) {
|
||||
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
|
||||
}
|
||||
|
||||
private static PriceAlertResponse toResponse(PriceAlert alert) {
|
||||
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
|
||||
alert.getCity(), alert.getRegion(),
|
||||
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
|
||||
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
|
||||
alert.isNotificationsEnabled(), alert.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,35 @@ public class GusBdlClient {
|
||||
return matchCityUnit(results, city);
|
||||
}
|
||||
|
||||
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */
|
||||
static final long WAGE_VARIABLE_ID = 64428;
|
||||
|
||||
public record AnnualValue(int year, java.math.BigDecimal value) {
|
||||
}
|
||||
|
||||
/** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */
|
||||
public AnnualValue latestAverageWage() throws IOException, InterruptedException {
|
||||
JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100");
|
||||
return parseLatestAnnualValue(response);
|
||||
}
|
||||
|
||||
static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException {
|
||||
AnnualValue latest = null;
|
||||
for (JsonNode result : response.path("results")) {
|
||||
for (JsonNode value : result.path("values")) {
|
||||
int year = value.path("year").asInt(0);
|
||||
java.math.BigDecimal amount = value.path("val").decimalValue();
|
||||
if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) {
|
||||
latest = new AnnualValue(year, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (latest == null) {
|
||||
throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
public List<Variable> variables() throws IOException, InterruptedException {
|
||||
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
|
||||
}
|
||||
|
||||
@@ -1,56 +1,35 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
||||
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
|
||||
*
|
||||
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
|
||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
||||
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
|
||||
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
|
||||
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
|
||||
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/autopay")
|
||||
public class AutoPayItnController {
|
||||
|
||||
private final AutoPayService autoPayService;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final PromotionService promotionService;
|
||||
private final AutoPayItnService itnService;
|
||||
|
||||
public AutoPayItnController(AutoPayService autoPayService,
|
||||
PaymentConfigService paymentConfigService,
|
||||
PromotionService promotionService) {
|
||||
this.autoPayService = autoPayService;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.promotionService = promotionService;
|
||||
public AutoPayItnController(AutoPayItnService itnService) {
|
||||
this.itnService = itnService;
|
||||
}
|
||||
|
||||
@PostMapping("/itn")
|
||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!autoPayService.verifyItn(params, cfg)) {
|
||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
||||
}
|
||||
String orderId = params.get("OrderID");
|
||||
if (orderId != null && isSuccess(params)) {
|
||||
promotionService.markPaidByExternalId(orderId);
|
||||
}
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
|
||||
private static boolean isSuccess(Map<String, String> params) {
|
||||
String status = params.getOrDefault("PaymentStatus",
|
||||
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
|
||||
String s = status == null ? "" : status.trim().toUpperCase();
|
||||
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
|
||||
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
|
||||
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_XML)
|
||||
.body(itnService.handle(transactions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.payment.AutoPayItnParser;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
|
||||
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
|
||||
*
|
||||
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
|
||||
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
|
||||
* 2. zgodnosc identyfikatora serwisu,
|
||||
* 3. istnienie zamowienia o danym OrderID,
|
||||
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
|
||||
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
|
||||
*
|
||||
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
|
||||
*/
|
||||
@Service
|
||||
public class AutoPayItnService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
|
||||
private static final String CONFIRMED = "CONFIRMED";
|
||||
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
|
||||
private static final String NL = "\n";
|
||||
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final AutoPayService autoPayService;
|
||||
private final PromotionOrderRepository orderRepository;
|
||||
private final PromotionService promotionService;
|
||||
|
||||
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
|
||||
PromotionOrderRepository orderRepository, PromotionService promotionService) {
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.autoPayService = autoPayService;
|
||||
this.orderRepository = orderRepository;
|
||||
this.promotionService = promotionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
|
||||
*
|
||||
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
|
||||
*/
|
||||
@Transactional
|
||||
public String handle(String transactionsBase64) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
|
||||
|
||||
boolean authentic = verifySignature(cfg, itn);
|
||||
if (!authentic) {
|
||||
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
|
||||
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
|
||||
}
|
||||
|
||||
List<Confirmation> confirmations = new ArrayList<>();
|
||||
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
|
||||
String result = authentic ? process(tx) : NOT_CONFIRMED;
|
||||
confirmations.add(new Confirmation(tx.orderId(), result));
|
||||
}
|
||||
return response(cfg, confirmations);
|
||||
}
|
||||
|
||||
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
|
||||
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
|
||||
return false;
|
||||
}
|
||||
String expected = autoPayService.hash(cfg, itn.signedValues());
|
||||
return AutoPayService.hashMatches(expected, itn.hash());
|
||||
}
|
||||
|
||||
private String process(AutoPayItnParser.ItnTransaction tx) {
|
||||
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
|
||||
if (found.isEmpty()) {
|
||||
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
|
||||
return NOT_CONFIRMED;
|
||||
}
|
||||
PromotionOrder order = found.get();
|
||||
|
||||
if (!tx.success()) {
|
||||
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
|
||||
// ale niczego nie naliczamy.
|
||||
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
|
||||
order.setStatus(OrderStatus.FAILED);
|
||||
orderRepository.save(order);
|
||||
}
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
|
||||
// ale nie nalicza drugi raz kredytow ani wyroznienia.
|
||||
if (order.getStatus() == OrderStatus.PAID) {
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
|
||||
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
|
||||
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
|
||||
return NOT_CONFIRMED;
|
||||
}
|
||||
|
||||
promotionService.applyPaid(order);
|
||||
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
|
||||
return CONFIRMED;
|
||||
}
|
||||
|
||||
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
|
||||
static boolean amountMatches(BigDecimal expected, String actual) {
|
||||
if (expected == null || actual == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return expected.compareTo(new BigDecimal(actual)) == 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private record Confirmation(String orderId, String result) {
|
||||
}
|
||||
|
||||
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
|
||||
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
|
||||
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
|
||||
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
|
||||
List<String> values = new ArrayList<>();
|
||||
values.add(serviceId);
|
||||
for (Confirmation c : confirmations) {
|
||||
values.add(c.orderId());
|
||||
values.add(c.result());
|
||||
}
|
||||
String hash = autoPayService.hash(cfg, values);
|
||||
|
||||
StringBuilder entries = new StringBuilder();
|
||||
for (Confirmation c : confirmations) {
|
||||
entries.append(" <transactionConfirmed>").append(NL)
|
||||
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
|
||||
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
|
||||
.append(" </transactionConfirmed>").append(NL);
|
||||
}
|
||||
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
|
||||
+ "<confirmationList>" + NL
|
||||
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
|
||||
+ " <transactionsConfirmations>" + NL
|
||||
+ entries
|
||||
+ " </transactionsConfirmations>" + NL
|
||||
+ " <hash>" + hash + "</hash>" + NL
|
||||
+ "</confirmationList>";
|
||||
}
|
||||
|
||||
private static String decode(String base64) {
|
||||
try {
|
||||
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||
|
||||
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
||||
@RestController
|
||||
@@ -51,6 +52,11 @@ public class PromotionController {
|
||||
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
||||
}
|
||||
|
||||
@GetMapping("/order/{id}/autopay-form")
|
||||
public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) {
|
||||
return promotionService.autoPayForm(authentication.getName(), id);
|
||||
}
|
||||
|
||||
public record CreditsResponse(int credits) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
||||
Optional<PromotionOrder> findByExternalId(String externalId);
|
||||
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
||||
|
||||
/**
|
||||
* Zamowienie z blokada wiersza do konca transakcji - rownolegle notyfikacje ITN o tym samym
|
||||
* zamowieniu obsluguja sie po kolei, wiec kredyty nie zostana naliczone dwa razy.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select o from PromotionOrder o where o.externalId = :externalId")
|
||||
Optional<PromotionOrder> findForUpdateByExternalId(@Param("externalId") String externalId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
@@ -27,7 +28,7 @@ public class PromotionService {
|
||||
private final ListingRepository listingRepository;
|
||||
private final PaymentConfigService paymentConfigService;
|
||||
private final AutoPayService autoPayService;
|
||||
private final String baseUrl;
|
||||
private final boolean autoConfirmPayments;
|
||||
|
||||
public PromotionService(PromotionPlanRepository planRepository,
|
||||
PromotionPackageRepository packageRepository,
|
||||
@@ -36,7 +37,7 @@ public class PromotionService {
|
||||
ListingRepository listingRepository,
|
||||
PaymentConfigService paymentConfigService,
|
||||
AutoPayService autoPayService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
|
||||
this.planRepository = planRepository;
|
||||
this.packageRepository = packageRepository;
|
||||
this.orderRepository = orderRepository;
|
||||
@@ -44,7 +45,7 @@ public class PromotionService {
|
||||
this.listingRepository = listingRepository;
|
||||
this.paymentConfigService = paymentConfigService;
|
||||
this.autoPayService = autoPayService;
|
||||
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
|
||||
this.autoConfirmPayments = autoConfirmPayments;
|
||||
}
|
||||
|
||||
// --- Publiczne (uzytkownik) ---
|
||||
@@ -63,6 +64,14 @@ public class PromotionService {
|
||||
|
||||
@Transactional
|
||||
public CheckoutResponse checkout(String email, CheckoutRequest request) {
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
// Bez skonfigurowanej bramki nie ma komu zaplacic - zamowienie uznane "od reki" oznaczaloby
|
||||
// darmowe promowanie dla kazdego. Symulacja platnosci tylko przy jawnie wlaczonym APP_PAYMENTS_AUTO_CONFIRM (lokalnie).
|
||||
if (!cfg.isLive() && !autoConfirmPayments) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
|
||||
}
|
||||
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setUserEmail(email);
|
||||
order.setKind(request.kind());
|
||||
@@ -70,8 +79,7 @@ public class PromotionService {
|
||||
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
||||
|
||||
if (request.kind() == PromotionKind.PLAN) {
|
||||
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PromotionPlan plan = requireActivePlan(request.planId());
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
order.setRefId(plan.getId());
|
||||
order.setItemName("Promowanie: " + plan.getName());
|
||||
@@ -79,6 +87,7 @@ public class PromotionService {
|
||||
order.setListingId(listing.getId());
|
||||
} else {
|
||||
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
||||
.filter(PromotionPackage::isActive)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
order.setRefId(pack.getId());
|
||||
order.setItemName("Pakiet promowan: " + pack.getName());
|
||||
@@ -86,16 +95,43 @@ public class PromotionService {
|
||||
}
|
||||
order = orderRepository.save(order);
|
||||
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!cfg.isLive()) {
|
||||
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
|
||||
// Lokalny tryb bez bramki (app.payments.auto-confirm) - platnosc uznajemy od razu.
|
||||
applyPaid(order);
|
||||
return CheckoutResponse.paid(order.getId());
|
||||
}
|
||||
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
|
||||
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
|
||||
order.getItemName(), email, returnUrl);
|
||||
return CheckoutResponse.redirect(order.getId(), url);
|
||||
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
|
||||
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
|
||||
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
|
||||
}
|
||||
|
||||
/** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */
|
||||
public AutoPayStartForm autoPayForm(String email, Long orderId) {
|
||||
PromotionOrder order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
|
||||
if (!order.getUserEmail().equalsIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
|
||||
}
|
||||
if (order.getStatus() != OrderStatus.PENDING) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność");
|
||||
}
|
||||
PaymentConfig cfg = paymentConfigService.get();
|
||||
if (!cfg.isLive()) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
|
||||
}
|
||||
return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(),
|
||||
paymentDescription(order), order.getUserEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki
|
||||
* alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu.
|
||||
*/
|
||||
static String paymentDescription(PromotionOrder order) {
|
||||
return order.getKind() == PromotionKind.PLAN
|
||||
? "Promowanie ogloszenia nr " + order.getListingId()
|
||||
: "Pakiet promowan, zamowienie nr " + order.getId();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -115,33 +151,24 @@ public class PromotionService {
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
|
||||
userRepository.save(user);
|
||||
userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
|
||||
}
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
}
|
||||
|
||||
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
|
||||
@Transactional
|
||||
public void markPaidByExternalId(String externalId) {
|
||||
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||
if (user.getPromotionCredits() <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||
}
|
||||
PromotionPlan plan = planRepository.findById(request.planId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
PromotionPlan plan = requireActivePlan(request.planId());
|
||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||
|
||||
user.setPromotionCredits(user.getPromotionCredits() - 1);
|
||||
userRepository.save(user);
|
||||
// Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
|
||||
if (userRepository.consumePromotionCredit(user.getId()) == 0) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||
}
|
||||
extendPromotion(listing, plan.getDurationDays());
|
||||
listingRepository.save(listing);
|
||||
|
||||
@@ -158,7 +185,15 @@ public class PromotionService {
|
||||
order.setPaidAt(Instant.now());
|
||||
orderRepository.save(order);
|
||||
|
||||
return user.getPromotionCredits();
|
||||
return credits(email);
|
||||
}
|
||||
|
||||
// Kupic (i wydac na nie kredyt) mozna tylko plan wystawiony w ofercie - wylaczony przez
|
||||
// administracje plan (np. o starej cenie) jest niedostepny takze przy wywolaniu API wprost.
|
||||
private PromotionPlan requireActivePlan(Long planId) {
|
||||
return planRepository.findById(requireId(planId, "planId"))
|
||||
.filter(PromotionPlan::isActive)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||
}
|
||||
|
||||
public String orderStatus(String email, Long id) {
|
||||
@@ -284,7 +319,8 @@ public class PromotionService {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
|
||||
private static String generateExternalId() {
|
||||
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
|
||||
return java.util.UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,24 @@ public class ListingReportController {
|
||||
this.userReportService = userReportService;
|
||||
}
|
||||
|
||||
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
|
||||
// zgloszonego) widzi tylko administracja.
|
||||
@PostMapping("/listings")
|
||||
public ListingReportResponse createListingReport(
|
||||
public ReportReceiptResponse createListingReport(
|
||||
@Valid @RequestBody CreateListingReportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
return listingReportService.create(request, authentication.getName());
|
||||
ListingReportResponse report = listingReportService.create(request, authentication.getName());
|
||||
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||
}
|
||||
|
||||
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
|
||||
@PostMapping("/users")
|
||||
public UserReportResponse createUserReport(
|
||||
public ReportReceiptResponse createUserReport(
|
||||
@Valid @RequestBody CreateUserReportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
return userReportService.create(request, authentication.getName());
|
||||
UserReportResponse report = userReportService.create(request, authentication.getName());
|
||||
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
@@ -30,7 +31,10 @@ public class ListingReportService {
|
||||
|
||||
@Transactional
|
||||
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
||||
// Zglaszac mozna tylko opublikowane ogloszenia - inaczej kolejne numery pozwalaly sprawdzac
|
||||
// istnienie ogloszen oczekujacych i odrzuconych.
|
||||
PropertyListing listing = listingRepository.findById(request.listingId())
|
||||
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||
|
||||
ListingReport report = new ListingReport();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Potwierdzenie przyjecia zgloszenia dla zglaszajacego. Celowo bez danych zgloszonego (e-mail,
|
||||
* nazwa, czy ma konto) i ogloszenia - pelne zgloszenie widzi tylko administracja.
|
||||
*/
|
||||
public record ReportReceiptResponse(Long id, Instant createdAt, String reasonTitle, ListingReportStatus status) {
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingService;
|
||||
import pl.polskalokalnie.message.Conversation;
|
||||
import pl.polskalokalnie.message.ConversationRepository;
|
||||
import pl.polskalokalnie.message.Message;
|
||||
@@ -20,19 +21,21 @@ public class UserReportService {
|
||||
|
||||
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
|
||||
private static final int MAX_CONVERSATION_MESSAGES = 50;
|
||||
private static final int MAX_MESSAGE_LENGTH = 2000;
|
||||
|
||||
private final UserReportRepository userReportRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
private final ListingService listingService;
|
||||
|
||||
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
|
||||
ConversationRepository conversationRepository, MessageRepository messageRepository) {
|
||||
ConversationRepository conversationRepository, MessageRepository messageRepository,
|
||||
ListingService listingService) {
|
||||
this.userReportRepository = userReportRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -42,7 +45,6 @@ public class UserReportService {
|
||||
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
|
||||
report.setListingId(request.listingId());
|
||||
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
|
||||
report.setConversation(cleanConversation(request.conversation()));
|
||||
report.setReasonId(request.reasonId().trim());
|
||||
report.setReasonTitle(request.reasonTitle().trim());
|
||||
report.setDetails(ReportAttachments.trimToNull(request.details()));
|
||||
@@ -53,7 +55,8 @@ public class UserReportService {
|
||||
|
||||
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
|
||||
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
|
||||
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje.
|
||||
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje, a tresci rozmowy
|
||||
// przyslanej przez przegladarke nie zapisujemy - administracja wzielaby ja za dowod.
|
||||
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
|
||||
Conversation conversation = reporterConversation(request.conversationId(), reporter);
|
||||
AppUser reported;
|
||||
@@ -103,6 +106,7 @@ public class UserReportService {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||
}
|
||||
|
||||
listingService.deleteAllOfOwner(reported.getEmail());
|
||||
userRepository.delete(reported);
|
||||
|
||||
report.setReportedUserDeleted(true);
|
||||
@@ -115,34 +119,6 @@ public class UserReportService {
|
||||
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
|
||||
}
|
||||
|
||||
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
|
||||
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
|
||||
if (payloads == null || payloads.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
|
||||
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
|
||||
: payloads;
|
||||
|
||||
List<UserReportMessage> cleaned = new ArrayList<>();
|
||||
for (UserReportMessagePayload payload : tail) {
|
||||
if (payload == null) {
|
||||
continue;
|
||||
}
|
||||
String content = payload.content() == null ? "" : payload.content().trim();
|
||||
if (content.length() > MAX_MESSAGE_LENGTH) {
|
||||
content = content.substring(0, MAX_MESSAGE_LENGTH);
|
||||
}
|
||||
UserReportMessage message = new UserReportMessage();
|
||||
message.setFromReporter(payload.fromReporter());
|
||||
message.setContent(content);
|
||||
message.setSentAt(payload.sentAt());
|
||||
message.setHasPhoto(payload.hasPhoto());
|
||||
cleaned.add(message);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
|
||||
private Conversation reporterConversation(String conversationId, AppUser reporter) {
|
||||
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
|
||||
|
||||
@@ -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(',', ' ');
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,11 @@ public class MessageComposer {
|
||||
|
||||
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
|
||||
public String composeEmail(String rawBody, String recipientName, Long leadId) {
|
||||
String personalized = personalize(rawBody, recipientName);
|
||||
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
|
||||
// O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
|
||||
// formularza, wiec do tresci HTML trafia escapowane - inaczej wstrzyknelo by link do maila.
|
||||
String html = looksLikeHtml(rawBody)
|
||||
? personalize(rawBody, recipientName == null ? null : escapeHtml(recipientName))
|
||||
: textToHtml(personalize(rawBody, recipientName));
|
||||
String link = unsubscribeLink(leadId, "email");
|
||||
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
|
||||
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
|
||||
@@ -64,6 +67,7 @@ public class MessageComposer {
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/**
|
||||
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
||||
@@ -20,17 +22,40 @@ public class UnsubscribeController {
|
||||
this.unsubscribeService = unsubscribeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
|
||||
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
|
||||
*/
|
||||
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||
public String unsubscribe(@RequestParam("token") String token) {
|
||||
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
||||
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
||||
return page("Rezygnacja przyjęta",
|
||||
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
|
||||
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
|
||||
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
|
||||
public String confirm(@RequestParam("token") String token) {
|
||||
UnsubscribeService.Result result = unsubscribeService.preview(token);
|
||||
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
|
||||
+ "<button type=\"submit\">Wypisz mnie</button></form>";
|
||||
return page("Rezygnacja z wiadomości",
|
||||
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
|
||||
}
|
||||
|
||||
private static String page(String title, String message) {
|
||||
/**
|
||||
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
|
||||
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
|
||||
*/
|
||||
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||
public String unsubscribe(@RequestParam("token") String token) {
|
||||
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
||||
return page("Rezygnacja przyjęta",
|
||||
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
|
||||
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
|
||||
}
|
||||
|
||||
private static String contact(UnsubscribeService.Result result) {
|
||||
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
|
||||
}
|
||||
|
||||
private static String channel(UnsubscribeService.Result result) {
|
||||
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
||||
}
|
||||
|
||||
private static String page(String title, String message, String action) {
|
||||
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||
+ "<title>" + title + "</title>"
|
||||
@@ -38,7 +63,10 @@ public class UnsubscribeController {
|
||||
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
|
||||
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
|
||||
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
|
||||
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>"
|
||||
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
|
||||
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
|
||||
+ "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
|
||||
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
|
||||
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
|
||||
+ "</div></body></html>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,28 +41,17 @@ public class UnsubscribeService {
|
||||
return encodedPayload + "." + sign(encodedPayload);
|
||||
}
|
||||
|
||||
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
|
||||
public Result preview(String token) {
|
||||
Target target = parse(token);
|
||||
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Result unsubscribe(String token) {
|
||||
if (token == null || !token.contains(".")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||
}
|
||||
int dot = token.lastIndexOf('.');
|
||||
String encodedPayload = token.substring(0, dot);
|
||||
String signature = token.substring(dot + 1);
|
||||
if (!sign(encodedPayload).equals(signature)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
||||
}
|
||||
|
||||
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
||||
String[] parts = payload.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||
}
|
||||
Long leadId = parseLeadId(parts[0]);
|
||||
String channel = normalizeChannel(parts[1]);
|
||||
|
||||
Lead lead = leadRepository.findById(leadId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
||||
Target target = parse(token);
|
||||
Lead lead = target.lead();
|
||||
String channel = target.channel();
|
||||
|
||||
if ("sms".equals(channel)) {
|
||||
lead.setSmsOptOut(true);
|
||||
@@ -76,6 +65,38 @@ public class UnsubscribeService {
|
||||
return new Result(channel, maskContact(lead, channel));
|
||||
}
|
||||
|
||||
private record Target(Lead lead, String channel) {
|
||||
}
|
||||
|
||||
private Target parse(String token) {
|
||||
if (token == null || !token.contains(".")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||
}
|
||||
int dot = token.lastIndexOf('.');
|
||||
String encodedPayload = token.substring(0, dot);
|
||||
String signature = token.substring(dot + 1);
|
||||
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
|
||||
// ile poczatkowych znakow podpisu sie zgadza.
|
||||
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
|
||||
signature.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
||||
}
|
||||
|
||||
String payload;
|
||||
try {
|
||||
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||
}
|
||||
String[] parts = payload.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||
}
|
||||
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
||||
return new Target(lead, normalizeChannel(parts[1]));
|
||||
}
|
||||
|
||||
private String sign(String data) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALGO);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Usuniecie danych osobowych konta (art. 17 RODO). Dotad kasowany byl tylko wiersz app_users,
|
||||
* ustawienia i ogloszenia - reszta zostawala w bazie jako sieroty: wiadomosci, powiadomienia,
|
||||
* alerty, spotkania, tokeny, kody SMS z numerem telefonu i lead marketingowy.
|
||||
*
|
||||
* <p>Zostaja wylacznie dane, ktorych usuniecie byloby sprzeczne z innym obowiazkiem:
|
||||
* zamowienia promowania (dokumentacja rozliczeniowa), zgloszenia ogloszen i uzytkownikow
|
||||
* oraz korespondencja z obsluga (obrona przed roszczeniami i obowiazki moderacyjne).
|
||||
* Te kategorie maja wlasne okresy przechowywania opisane w Polityce prywatnosci.</p>
|
||||
*
|
||||
* <p>Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie,
|
||||
* tabela po tabeli - dzieki temu widac wprost, co znika.</p>
|
||||
*/
|
||||
@Service
|
||||
public class AccountErasureService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AccountErasureService.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public AccountErasureService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void eraseFor(Long userId, String email) {
|
||||
if (userId == null || email == null || email.isBlank()) {
|
||||
return;
|
||||
}
|
||||
int removed = 0;
|
||||
|
||||
// Dane powiazane z identyfikatorem konta.
|
||||
removed += update("DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?", userId, userId);
|
||||
removed += update("DELETE FROM conversations WHERE buyer_id = ? OR seller_id = ?", userId, userId);
|
||||
removed += update("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?", userId, userId);
|
||||
removed += update("DELETE FROM meetings WHERE proposer_id = ? OR counterpart_id = ?", userId, userId);
|
||||
|
||||
// Dane powiazane z adresem e-mail.
|
||||
removed += update("DELETE FROM listing_favorites WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM saved_searches WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM price_alert_events WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM price_alerts WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM notifications WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM auth_tokens WHERE lower(user_email) = lower(?)", email);
|
||||
removed += update("DELETE FROM phone_otps WHERE lower(user_email) = lower(?)", email);
|
||||
|
||||
// Baza marketingowa: lead, jego tagi i pozycje w kolejkach kampanii.
|
||||
removed += update("DELETE FROM campaign_recipients WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
|
||||
removed += update("DELETE FROM lead_tags WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
|
||||
removed += update("DELETE FROM leads WHERE lower(email) = lower(?)", email);
|
||||
|
||||
// Poradniki uzytkownika znikaja razem z kontem - tak samo jak jego ogloszenia.
|
||||
removed += update("DELETE FROM article_attachments WHERE article_id IN (SELECT id FROM articles WHERE lower(author_email) = lower(?))", email);
|
||||
removed += update("DELETE FROM articles WHERE lower(author_email) = lower(?)", email);
|
||||
|
||||
log.info("Usunieto {} rekordow powiazanych z kontem id={}", removed, userId);
|
||||
}
|
||||
|
||||
private int update(String sql, Object... args) {
|
||||
try {
|
||||
return jdbcTemplate.update(sql, args);
|
||||
} catch (RuntimeException ex) {
|
||||
// Brak tabeli w starszej bazie nie moze zablokowac usuniecia konta.
|
||||
log.warn("Nie udalo sie wykonac czyszczenia: {} ({})", sql, ex.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -95,10 +95,21 @@ public class AppUser {
|
||||
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
||||
private Instant passwordChangedAt;
|
||||
|
||||
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
|
||||
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
|
||||
private Instant sessionsValidFrom;
|
||||
|
||||
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
|
||||
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
|
||||
private Instant lastActiveAt;
|
||||
|
||||
/**
|
||||
* Moment akceptacji Regulaminu i Polityki prywatnosci przy rejestracji. Zapisujemy go,
|
||||
* zeby mozna bylo wykazac, kiedy uzytkownik zaakceptowal dokumenty (art. 7 ust. 1 RODO).
|
||||
*/
|
||||
@Column(name = "terms_accepted_at")
|
||||
private Instant termsAcceptedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
@@ -209,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;
|
||||
}
|
||||
@@ -261,6 +264,23 @@ public class AppUser {
|
||||
this.passwordChangedAt = passwordChangedAt;
|
||||
}
|
||||
|
||||
public Instant getSessionsValidFrom() {
|
||||
return sessionsValidFrom;
|
||||
}
|
||||
|
||||
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
|
||||
public void revokeSessions() {
|
||||
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
}
|
||||
|
||||
public Instant getTermsAcceptedAt() {
|
||||
return termsAcceptedAt;
|
||||
}
|
||||
|
||||
public void setTermsAcceptedAt(Instant termsAcceptedAt) {
|
||||
this.termsAcceptedAt = termsAcceptedAt;
|
||||
}
|
||||
|
||||
public Instant getLastActiveAt() {
|
||||
return lastActiveAt;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package pl.polskalokalnie.user;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
|
||||
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
Optional<AppUser> findFirstByRole(Role role);
|
||||
|
||||
long countByRole(Role role);
|
||||
|
||||
/**
|
||||
* Zuzycie jednego kredytu promowania jednym zapytaniem z warunkiem - dwa rownolegle zadania
|
||||
* nie wydadza tego samego kredytu dwa razy. Zwraca 0, gdy kredytow juz nie ma.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("update AppUser u set u.promotionCredits = u.promotionCredits - 1 where u.id = :id and u.promotionCredits > 0")
|
||||
int consumePromotionCredit(@Param("id") Long id);
|
||||
|
||||
/** Dopisanie kredytow bez odczytu i zapisu calego konta (nie nadpisze rownoleglego zuzycia). */
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("update AppUser u set u.promotionCredits = u.promotionCredits + :amount where u.id = :id")
|
||||
int addPromotionCredits(@Param("id") Long id, @Param("amount") int amount);
|
||||
}
|
||||
|
||||
@@ -110,9 +110,13 @@ 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.
|
||||
* To zwykla historia przegladania - nie ma z nia zwiazanych zadnych powiadomien.
|
||||
*/
|
||||
@Column(name = "price_history_markets", columnDefinition = "text")
|
||||
private String priceHistoryMarkets;
|
||||
|
||||
// --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) ---
|
||||
|
||||
@@ -302,18 +306,18 @@ public class UserSettings {
|
||||
this.notifyMessages = notifyMessages;
|
||||
}
|
||||
|
||||
public boolean isNotifyProductNews() {
|
||||
return notifyProductNews;
|
||||
}
|
||||
|
||||
public void setNotifyProductNews(boolean notifyProductNews) {
|
||||
this.notifyProductNews = notifyProductNews;
|
||||
}
|
||||
|
||||
public String getSearchLocations() {
|
||||
return searchLocations;
|
||||
}
|
||||
|
||||
public String getPriceHistoryMarkets() {
|
||||
return priceHistoryMarkets;
|
||||
}
|
||||
|
||||
public void setPriceHistoryMarkets(String priceHistoryMarkets) {
|
||||
this.priceHistoryMarkets = priceHistoryMarkets;
|
||||
}
|
||||
|
||||
public void setSearchLocations(String searchLocations) {
|
||||
this.searchLocations = searchLocations;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public record UserSettingsRequest(
|
||||
Boolean notifySavedSearches,
|
||||
Boolean notifyPriceAlerts,
|
||||
Boolean notifyMessages,
|
||||
Boolean notifyProductNews,
|
||||
String priceHistoryMarkets,
|
||||
String searchLocations,
|
||||
String searchPropertyType,
|
||||
Integer searchBudgetMax,
|
||||
|
||||
@@ -21,7 +21,7 @@ public record UserSettingsResponse(
|
||||
boolean notifySavedSearches,
|
||||
boolean notifyPriceAlerts,
|
||||
boolean notifyMessages,
|
||||
boolean notifyProductNews,
|
||||
String priceHistoryMarkets,
|
||||
String searchLocations,
|
||||
String searchPropertyType,
|
||||
Integer searchBudgetMax,
|
||||
@@ -51,7 +51,7 @@ public record UserSettingsResponse(
|
||||
settings.isNotifySavedSearches(),
|
||||
settings.isNotifyPriceAlerts(),
|
||||
settings.isNotifyMessages(),
|
||||
settings.isNotifyProductNews(),
|
||||
settings.getPriceHistoryMarkets(),
|
||||
settings.getSearchLocations(),
|
||||
settings.getSearchPropertyType(),
|
||||
settings.getSearchBudgetMax(),
|
||||
|
||||
@@ -132,8 +132,9 @@ 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);
|
||||
}
|
||||
if (request.searchLocations() != null) {
|
||||
String locations = request.searchLocations().trim();
|
||||
|
||||
@@ -25,6 +25,10 @@ app:
|
||||
# W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa.
|
||||
secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min}
|
||||
expiration-seconds: ${APP_JWT_EXPIRATION_SECONDS:86400}
|
||||
payments:
|
||||
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest od razu uznawany za oplacony.
|
||||
# Tylko do pracy lokalnej - na produkcji oznaczaloby darmowe promowanie dla kazdego.
|
||||
auto-confirm: ${APP_PAYMENTS_AUTO_CONFIRM:false}
|
||||
admin:
|
||||
email: ${APP_ADMIN_EMAIL:admin@softspm.com}
|
||||
password: ${APP_ADMIN_PASSWORD:Admin123!}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package pl.polskalokalnie;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import pl.polskalokalnie.lead.Lead;
|
||||
import pl.polskalokalnie.lead.LeadRepository;
|
||||
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||
|
||||
/** Publiczne endpointy - regresje z audytu bezpieczenstwa 2026-09-23. */
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class PublicEndpointsSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private LeadRepository leadRepository;
|
||||
@Autowired
|
||||
private UnsubscribeService unsubscribeService;
|
||||
|
||||
@Test
|
||||
void privateListingListsRequireLogin() throws Exception {
|
||||
// Regresja: publiczna regula GET /api/listings/** przepuszczala je anonimowo (blad 500).
|
||||
mockMvc.perform(get("/api/listings/favorites")).andExpect(status().isForbidden());
|
||||
mockMvc.perform(get("/api/listings/mine/saves")).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void phoneCodeResendDoesNotRevealWhetherAccountExists() throws Exception {
|
||||
// Regresja: nieznany adres dawal 404 "Uzytkownik nie istnieje".
|
||||
mockMvc.perform(post("/api/auth/resend-phone-otp")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"nikogo-takiego@example.com\"}"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationRequiresEightCharacterPassword() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"nowy@example.com\",\"password\":\"abc123\",\"fullName\":\"Jan Nowak\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openingUnsubscribeLinkOnlyAsksForConfirmation() throws Exception {
|
||||
// Regresja: sam GET (np. skaner linkow w poczcie) wypisywal z mailingu.
|
||||
Lead lead = new Lead();
|
||||
lead.setEmail("subskrybent@example.com");
|
||||
lead.setEmailConsent(true);
|
||||
lead = leadRepository.save(lead);
|
||||
String token = unsubscribeService.generateToken(lead.getId(), "email");
|
||||
|
||||
mockMvc.perform(get("/api/unsubscribe").param("token", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("method=\"post\"")));
|
||||
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isFalse();
|
||||
|
||||
mockMvc.perform(post("/api/unsubscribe").param("token", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("Rezygnacja przyjęta")));
|
||||
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedUnsubscribeTokenIsRejected() throws Exception {
|
||||
mockMvc.perform(post("/api/unsubscribe").param("token", "MTpbZW1haWw.nieprawidlowy-podpis"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AccountEmailServiceTest {
|
||||
|
||||
@Test
|
||||
void greetingEscapesNameFromRegistrationForm() {
|
||||
// Regresja: rejestracja na cudzy e-mail z linkiem w polu imienia dawala prawdziwy mail
|
||||
// aktywacyjny z serwisu z linkiem atakujacego.
|
||||
String html = AccountEmailService.greeting("<a/href=https://evil.example>Kliknij</a>");
|
||||
|
||||
assertThat(html).doesNotContain("<a").contains("<a/href=");
|
||||
}
|
||||
|
||||
@Test
|
||||
void greetingUsesFirstName() {
|
||||
assertThat(AccountEmailService.greeting("Jan Kowalski")).isEqualTo("<p>Cześć Jan,</p>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
class JwtAuthFilterTest {
|
||||
|
||||
private AppUser user() {
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail("user@example.com");
|
||||
user.setRole(Role.USER);
|
||||
return user;
|
||||
}
|
||||
|
||||
private final JwtService jwtService = new JwtService("test-secret-key-for-junit-context-32chars-min", 3600);
|
||||
private final UserRepository userRepository = mock(UserRepository.class);
|
||||
private final JwtAuthFilter filter = new JwtAuthFilter(jwtService, userRepository);
|
||||
|
||||
/** Czy zadanie przeszlo przez filtr jako uwierzytelnione. */
|
||||
private boolean authenticated(MockHttpServletRequest request) throws Exception {
|
||||
AppUser user = user();
|
||||
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
|
||||
boolean[] result = new boolean[1];
|
||||
filter.doFilter(request, new MockHttpServletResponse(),
|
||||
(req, res) -> result[0] = SecurityContextHolder.getContext().getAuthentication() != null);
|
||||
SecurityContextHolder.clearContext();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private MockHttpServletRequest get(String uri) {
|
||||
return new MockHttpServletRequest("GET", uri);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionTokenWorksOnlyInHeader() throws Exception {
|
||||
String session = jwtService.generateToken(user());
|
||||
|
||||
MockHttpServletRequest header = get("/api/auth/me");
|
||||
header.addHeader("Authorization", "Bearer " + session);
|
||||
assertThat(authenticated(header)).isTrue();
|
||||
|
||||
// Regresja: token sesji w adresie (?access_token=) trafial do logow serwera.
|
||||
MockHttpServletRequest inUrl = get("/api/auth/me");
|
||||
inUrl.setParameter("access_token", session);
|
||||
assertThat(authenticated(inUrl)).isFalse();
|
||||
|
||||
MockHttpServletRequest asTicket = get(JwtAuthFilter.STREAM_PATH);
|
||||
asTicket.setParameter("ticket", session);
|
||||
assertThat(authenticated(asTicket)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamTokenWorksOnlyForNotificationStream() throws Exception {
|
||||
String ticket = jwtService.generateStreamToken("user@example.com");
|
||||
|
||||
MockHttpServletRequest stream = get(JwtAuthFilter.STREAM_PATH);
|
||||
stream.setParameter("ticket", ticket);
|
||||
assertThat(authenticated(stream)).isTrue();
|
||||
|
||||
MockHttpServletRequest otherPath = get("/api/auth/me");
|
||||
otherPath.setParameter("ticket", ticket);
|
||||
assertThat(authenticated(otherPath)).isFalse();
|
||||
|
||||
MockHttpServletRequest asSession = get("/api/auth/me");
|
||||
asSession.addHeader("Authorization", "Bearer " + ticket);
|
||||
assertThat(authenticated(asSession)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeUserWithFreshTokenIsAccepted() {
|
||||
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedUserLosesAccessImmediately() {
|
||||
// Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h.
|
||||
AppUser user = user();
|
||||
user.setBlocked(true);
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenIssuedBeforePasswordChangeIsRejected() {
|
||||
AppUser user = user();
|
||||
Instant oldToken = Instant.now().minusSeconds(60);
|
||||
user.revokeSessions();
|
||||
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse();
|
||||
// Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny.
|
||||
assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package pl.polskalokalnie.campaign;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.domain.Limit;
|
||||
import pl.polskalokalnie.lead.Lead;
|
||||
import pl.polskalokalnie.lead.LeadRepository;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||
import pl.polskalokalnie.send.EmailSender;
|
||||
import pl.polskalokalnie.send.SendResult;
|
||||
import pl.polskalokalnie.send.SmsSender;
|
||||
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||
|
||||
class CampaignSenderTest {
|
||||
|
||||
private CampaignRepository campaignRepository;
|
||||
private CampaignRecipientRepository recipientRepository;
|
||||
private LeadRepository leadRepository;
|
||||
private EmailSender emailSender;
|
||||
private CampaignSender sender;
|
||||
|
||||
private Campaign campaign;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
campaignRepository = mock(CampaignRepository.class);
|
||||
recipientRepository = mock(CampaignRecipientRepository.class);
|
||||
leadRepository = mock(LeadRepository.class);
|
||||
emailSender = mock(EmailSender.class);
|
||||
MailConfigService mailConfigService = mock(MailConfigService.class);
|
||||
UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
|
||||
|
||||
SmtpConfig smtp = new SmtpConfig();
|
||||
smtp.setEnabled(true);
|
||||
smtp.setHost("smtp.example.com");
|
||||
when(mailConfigService.getSmtp()).thenReturn(smtp);
|
||||
when(unsubscribeService.generateToken(any(), anyString())).thenReturn("token");
|
||||
when(emailSender.send(any(), anyString(), any(), anyString())).thenReturn(new SendResult(true, null, null));
|
||||
|
||||
campaign = new Campaign();
|
||||
campaign.setChannel(Channel.EMAIL);
|
||||
campaign.setSubject("Nowe oferty");
|
||||
campaign.setBody("<p>Treść</p>");
|
||||
campaign.setDailyLimit(200);
|
||||
campaign.setStatus(CampaignStatus.SENDING);
|
||||
when(campaignRepository.findByStatus(CampaignStatus.SCHEDULED)).thenReturn(List.of());
|
||||
when(campaignRepository.findByStatus(CampaignStatus.SENDING)).thenReturn(List.of(campaign));
|
||||
|
||||
sender = new CampaignSender(campaignRepository, recipientRepository, leadRepository, mailConfigService,
|
||||
emailSender, mock(SmsSender.class), unsubscribeService, "https://polskalokalnie.pl");
|
||||
}
|
||||
|
||||
private CampaignRecipient recipient(long leadId, String address) {
|
||||
CampaignRecipient recipient = new CampaignRecipient();
|
||||
recipient.setLeadId(leadId);
|
||||
recipient.setChannel(Channel.EMAIL);
|
||||
recipient.setAddress(address);
|
||||
recipient.setStatus(RecipientStatus.QUEUED);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
private Lead lead(boolean consent, boolean optOut) {
|
||||
Lead lead = new Lead();
|
||||
lead.setEmailConsent(consent);
|
||||
lead.setEmailOptOut(optOut);
|
||||
return lead;
|
||||
}
|
||||
|
||||
private void queue(List<CampaignRecipient> batch) {
|
||||
when(recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class)))
|
||||
.thenReturn(batch, List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsQueuedRecipientAndClosesCampaign() {
|
||||
CampaignRecipient recipient = recipient(1L, "kupujacy@example.com");
|
||||
queue(List.of(recipient));
|
||||
when(leadRepository.findById(1L)).thenReturn(Optional.of(lead(true, false)));
|
||||
|
||||
sender.processOutbox();
|
||||
|
||||
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.SENT);
|
||||
assertThat(recipient.getSentAt()).isNotNull();
|
||||
// Pusta kolejka po przebiegu = kampania rozliczona.
|
||||
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.COMPLETED);
|
||||
assertThat(campaign.getSentToday()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsRecipientWhoWithdrewConsentAfterBuildingOutbox() {
|
||||
CampaignRecipient recipient = recipient(2L, "rezygnacja@example.com");
|
||||
queue(List.of(recipient));
|
||||
when(leadRepository.findById(2L)).thenReturn(Optional.of(lead(true, true)));
|
||||
|
||||
sender.processOutbox();
|
||||
|
||||
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.OPTED_OUT);
|
||||
verify(emailSender, never()).send(any(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksFailureWithoutLosingTheRest() {
|
||||
CampaignRecipient recipient = recipient(3L, "blad@example.com");
|
||||
queue(List.of(recipient));
|
||||
when(leadRepository.findById(3L)).thenReturn(Optional.of(lead(true, false)));
|
||||
when(emailSender.send(any(), anyString(), any(), anyString()))
|
||||
.thenReturn(new SendResult(false, null, "Nie udało się połączyć"));
|
||||
|
||||
sender.processOutbox();
|
||||
|
||||
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.FAILED);
|
||||
assertThat(recipient.getError()).contains("połączyć");
|
||||
assertThat(campaign.getSentToday()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void waitsWhenDailyLimitIsUsedUp() {
|
||||
campaign.setDailyLimit(1);
|
||||
campaign.setSentToday(1);
|
||||
campaign.setSentTodayDate(java.time.LocalDate.now());
|
||||
|
||||
sender.processOutbox();
|
||||
|
||||
verify(recipientRepository, never())
|
||||
.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class));
|
||||
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.SENDING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pl.polskalokalnie.lead;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class AdvertisingEnquiryConsentTest {
|
||||
|
||||
private final LeadRepository repository = mock(LeadRepository.class);
|
||||
private final LeadService service = new LeadService(repository);
|
||||
|
||||
private AdvertisingEnquiryRequest enquiry(String name) {
|
||||
return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700",
|
||||
"Remonty", null, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() {
|
||||
// Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe.
|
||||
Lead existing = new Lead();
|
||||
ReflectionTestUtils.setField(existing, "id", 5L);
|
||||
existing.setEmail("ofiara@example.com");
|
||||
existing.setName("Prawdziwe Nazwisko");
|
||||
existing.setEmailConsent(false);
|
||||
existing.setSmsConsent(false);
|
||||
when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing));
|
||||
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
service.registerAdvertisingEnquiry(enquiry("<a href=https://evil>Kliknij</a>"));
|
||||
|
||||
assertThat(existing.isEmailConsent()).isFalse();
|
||||
assertThat(existing.isSmsConsent()).isFalse();
|
||||
assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko");
|
||||
}
|
||||
|
||||
@Test
|
||||
void newContactGetsConsentFromForm() {
|
||||
when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty());
|
||||
when(repository.findFirstByPhone(any())).thenReturn(Optional.empty());
|
||||
Lead[] saved = new Lead[1];
|
||||
when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0));
|
||||
|
||||
service.registerAdvertisingEnquiry(enquiry("Jan Kowalski"));
|
||||
|
||||
assertThat(saved[0].isEmailConsent()).isTrue();
|
||||
assertThat(saved[0].isSmsConsent()).isTrue();
|
||||
assertThat(saved[0].getName()).isEqualTo("Jan Kowalski");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
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;
|
||||
|
||||
class ListingEditModerationTest {
|
||||
|
||||
private static final String OWNER = "wlasciciel@example.com";
|
||||
private static final String PHOTO = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
|
||||
|
||||
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(SavedSearchNotifier.class));
|
||||
private PropertyListing listing;
|
||||
|
||||
private ListingCreateRequest request(String title, BigDecimal price, String phone) {
|
||||
return new ListingCreateRequest(title, "Opis mieszkania", OfferType.SALE, PropertyType.APARTMENT, "Kraków",
|
||||
null, price, 50.0, 2, null, null, null, null, null, null, null, null, null,
|
||||
"Jan", phone, OWNER, null, null, null, null, null, null, null, null,
|
||||
null, null, List.of(), List.of(), List.of(PHOTO), null, false);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
listing = new PropertyListing();
|
||||
listing.setOwnerEmail(OWNER);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(listing));
|
||||
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
// Stan po pierwszym zapisie i akceptacji przez administracje.
|
||||
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "500600700"));
|
||||
listing.setStatus(ListingStatus.APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void priceChangeStaysPublished() {
|
||||
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("480000"), "500600700"));
|
||||
|
||||
assertThat(listing.getStatus()).isEqualTo(ListingStatus.APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedContentGoesBackToModeration() {
|
||||
// Regresja: zatwierdzone ogloszenie mozna bylo podmienic na dowolna tresc bez weryfikacji.
|
||||
service.updateOwn(1L, OWNER, request("Okazja! Zadzwon teraz", new BigDecimal("500000"), "500600700"));
|
||||
|
||||
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedPhoneOfPausedListingGoesBackToModeration() {
|
||||
listing.setStatus(ListingStatus.PAUSED);
|
||||
|
||||
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "999888777"));
|
||||
|
||||
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ListingPrivacyTest {
|
||||
|
||||
private PropertyListing listing() {
|
||||
PropertyListing listing = new PropertyListing();
|
||||
listing.setOwnerEmail("wlasciciel@example.com");
|
||||
listing.setContactEmail("wlasciciel@example.com");
|
||||
listing.setStatus(ListingStatus.APPROVED);
|
||||
return listing;
|
||||
}
|
||||
|
||||
@Test
|
||||
void approximateLocationStaysInsideCircleAndDoesNotDependOnListingId() {
|
||||
// Regresja: przesuniecie liczone z jawnego id dalo sie odwrocic i odzyskac dokladny adres.
|
||||
PropertyListing a = listing();
|
||||
a.setLat(52.229676);
|
||||
a.setLng(21.012229);
|
||||
PropertyListing b = listing();
|
||||
b.setLat(52.229700);
|
||||
b.setLng(21.012300);
|
||||
|
||||
double lat = ListingDetailResponse.blurLat(a);
|
||||
double lng = ListingDetailResponse.blurLng(a);
|
||||
// Dwa rozne adresy w tej samej kratce daja ten sam punkt - nie da sie z niego wrocic do adresu.
|
||||
assertThat(ListingDetailResponse.blurLat(b)).isEqualTo(lat);
|
||||
assertThat(ListingDetailResponse.blurLng(b)).isEqualTo(lng);
|
||||
|
||||
double dLatM = (lat - a.getLat()) * 111_320d;
|
||||
double dLngM = (lng - a.getLng()) * 111_320d * Math.cos(Math.toRadians(a.getLat()));
|
||||
assertThat(Math.hypot(dLatM, dLngM)).isLessThan(300d);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicListDoesNotExposeOwnerEmail() {
|
||||
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
|
||||
assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull();
|
||||
assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicDetailHidesAccountEmailButOwnerSeesIt() {
|
||||
ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true);
|
||||
assertThat(publicView.ownerEmail()).isNull();
|
||||
assertThat(publicView.contactEmail()).isNull();
|
||||
assertThat(publicView.ownerId()).isEqualTo(7L);
|
||||
|
||||
ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false);
|
||||
assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com");
|
||||
assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class ListingServiceTest {
|
||||
|
||||
@Test
|
||||
void acceptsRasterImageDataUrls() {
|
||||
List<String> photos = List.of(
|
||||
"data:image/jpeg;base64,/9j/4AAQSkZJRg==",
|
||||
"data:image/png;base64,iVBORw0KGgo=",
|
||||
"data:image/webp;base64,UklGRg==");
|
||||
assertThat(ListingService.limitPhotos(photos)).containsExactlyElementsOf(photos);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPhotosThatCouldRunScriptOnSiteOrigin() {
|
||||
// Regresja: data URL z text/html lub SVG serwowany z /api/og/... uruchamial skrypt w domenie serwisu.
|
||||
for (String photo : List.of(
|
||||
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
|
||||
"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=",
|
||||
"https://evil.example/redirect",
|
||||
"javascript:alert(1)")) {
|
||||
assertThatThrownBy(() -> ListingService.limitPhotos(List.of(photo)))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ListingViewGuardTest {
|
||||
|
||||
@Test
|
||||
void countsOneViewPerVisitorAndListing() {
|
||||
// Regresja: kazde odswiezenie strony podbijalo licznik wyswietlen.
|
||||
ListingViewGuard guard = new ListingViewGuard();
|
||||
|
||||
assertThat(guard.shouldCount(1L, "203.0.113.1")).isTrue();
|
||||
assertThat(guard.shouldCount(1L, "203.0.113.1")).isFalse();
|
||||
assertThat(guard.shouldCount(1L, "203.0.113.2")).isTrue();
|
||||
assertThat(guard.shouldCount(2L, "203.0.113.1")).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pl.polskalokalnie.market;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Parsowanie odpowiedzi JSON-stat z Eurostatu - ksztalt jak w zbiorze irt_st_m. */
|
||||
class EurostatRateClientTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private String response(String values, String periods) {
|
||||
return "{\"value\":" + values + ",\"dimension\":{\"time\":{\"category\":{\"index\":" + periods + "}}}}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void bierzeNajpozniejszyMiesiacZWartoscia() throws Exception {
|
||||
String json = response("{\"0\":3.85,\"1\":3.82,\"2\":3.83}",
|
||||
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
|
||||
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
|
||||
assertThat(rate.period()).isEqualTo("2026-08");
|
||||
assertThat(rate.percent()).isEqualByComparingTo("3.83");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pomijaMiesiaceBezWartosci() throws Exception {
|
||||
// Ostatni okres bywa jeszcze pusty - wtedy bierzemy poprzedni, a nie zglaszamy bledu.
|
||||
String json = response("{\"0\":3.85,\"1\":3.82}",
|
||||
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
|
||||
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
|
||||
assertThat(rate.period()).isEqualTo("2026-07");
|
||||
assertThat(rate.percent()).isEqualByComparingTo("3.82");
|
||||
}
|
||||
|
||||
@Test
|
||||
void odrzucaOdpowiedzBezDanych() throws Exception {
|
||||
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree("{}")))
|
||||
.isInstanceOf(IOException.class);
|
||||
assertThatThrownBy(() -> EurostatRateClient.parseLatest(
|
||||
mapper.readTree(response("{}", "{\"2026-08\":0}"))))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void odrzucaNieprawidlowaStope() throws Exception {
|
||||
String json = response("{\"0\":0}", "{\"2026-08\":0}");
|
||||
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree(json)))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
class ConversationStartTest {
|
||||
|
||||
@Test
|
||||
void cannotStartConversationAboutUnpublishedListing() {
|
||||
// Regresja: rozmowa o ogloszeniu oczekujacym/odrzuconym ujawniala jego tytul, cene i sprzedajacego.
|
||||
ConversationRepository conversations = mock(ConversationRepository.class);
|
||||
ListingRepository listings = mock(ListingRepository.class);
|
||||
ConversationService service = new ConversationService(conversations, mock(MessageRepository.class),
|
||||
mock(UserBlockRepository.class), mock(UserRepository.class), listings,
|
||||
mock(TextModerationService.class), mock(ApplicationEventPublisher.class));
|
||||
|
||||
PropertyListing pending = new PropertyListing();
|
||||
pending.setStatus(ListingStatus.PENDING);
|
||||
pending.setOwnerEmail("sprzedajacy@example.com");
|
||||
when(listings.findById(5L)).thenReturn(Optional.of(pending));
|
||||
AppUser me = new AppUser();
|
||||
ReflectionTestUtils.setField(me, "id", 1L);
|
||||
when(conversations.findByListingIdAndBuyerId(5L, 1L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.start(me, 5L, "Dzień dobry"))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
|
||||
verify(conversations, never()).save(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package pl.polskalokalnie.og;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OgControllerTest {
|
||||
|
||||
@Test
|
||||
void decodesRasterImage() {
|
||||
OgController.DecodedImage image = OgController.decodeDataUrl("data:image/png;base64,iVBORw0KGgo=");
|
||||
assertThat(image).isNotNull();
|
||||
assertThat(image.mimeType()).isEqualTo("image/png");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesMimeTypesThatBrowsersExecute() {
|
||||
// Regresja: typ MIME z data URL trafial wprost do Content-Type odpowiedzi.
|
||||
assertThat(OgController.decodeDataUrl("data:text/html;base64,PHNjcmlwdD48L3NjcmlwdD4=")).isNull();
|
||||
assertThat(OgController.decodeDataUrl("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=")).isNull();
|
||||
assertThat(OgController.decodeDataUrl("https://evil.example/")).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package pl.polskalokalnie.pricealert;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
|
||||
* jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
|
||||
*/
|
||||
class PriceAlertThresholdTest {
|
||||
|
||||
private boolean notifiesLocation(long previous, long current) {
|
||||
double changePct = ((current - previous) * 100.0) / previous;
|
||||
return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresSmallMoveOfTheAverage() {
|
||||
// 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
|
||||
assertThat(notifiesLocation(14_000, 14_200)).isFalse();
|
||||
assertThat(notifiesLocation(14_000, 13_800)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifiesAboutRealMove() {
|
||||
assertThat(notifiesLocation(14_000, 14_280)).isTrue();
|
||||
assertThat(notifiesLocation(14_659, 13_523)).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pl.polskalokalnie.pricehistory;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Parsowanie rocznych wartosci z BDL - przecietne wynagrodzenie brutto dla calej Polski. */
|
||||
class GusWageParsingTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void bierzeNajnowszyRok() throws Exception {
|
||||
String json = "{\"results\":[{\"id\":\"000000000000\",\"name\":\"POLSKA\",\"values\":["
|
||||
+ "{\"year\":\"2023\",\"val\":7595.30},{\"year\":\"2025\",\"val\":9371.11},"
|
||||
+ "{\"year\":\"2024\",\"val\":8630.27}]}]}";
|
||||
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
|
||||
assertThat(wage.year()).isEqualTo(2025);
|
||||
assertThat(wage.value()).isEqualByComparingTo("9371.11");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pomijaLataBezWartosci() throws Exception {
|
||||
String json = "{\"results\":[{\"values\":[{\"year\":\"2024\",\"val\":8630.27},{\"year\":\"2025\",\"val\":0}]}]}";
|
||||
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
|
||||
assertThat(wage.year()).isEqualTo(2024);
|
||||
}
|
||||
|
||||
@Test
|
||||
void odrzucaPustaOdpowiedz() {
|
||||
assertThatThrownBy(() -> GusBdlClient.parseLatestAnnualValue(mapper.readTree("{\"results\":[]}")))
|
||||
.isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
|
||||
/**
|
||||
* Zabezpieczenia obslugi notyfikacji ITN (scenariusze przeniesione z Gieldy Komunalnej):
|
||||
* podszycie sie pod bramke, zanizenie kwoty, podmiana waluty, statusy nie-koncowe i powtorki.
|
||||
*/
|
||||
class AutoPayItnServiceTest {
|
||||
|
||||
private static final String SERVICE_ID = "218886";
|
||||
private static final String KEY = "sekretny-klucz-hash";
|
||||
private static final String ORDER_ID = "abc123";
|
||||
|
||||
private final AutoPayService autoPayService = new AutoPayService();
|
||||
private PromotionOrderRepository orderRepository;
|
||||
private PromotionService promotionService;
|
||||
private AutoPayItnService service;
|
||||
private PaymentConfig cfg;
|
||||
private PromotionOrder order;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
orderRepository = mock(PromotionOrderRepository.class);
|
||||
promotionService = mock(PromotionService.class);
|
||||
PaymentConfigService configService = mock(PaymentConfigService.class);
|
||||
cfg = new PaymentConfig();
|
||||
cfg.setServiceId(SERVICE_ID);
|
||||
cfg.setSecretKey(KEY);
|
||||
cfg.setEnabled(true);
|
||||
when(configService.get()).thenReturn(cfg);
|
||||
service = new AutoPayItnService(configService, autoPayService, orderRepository, promotionService);
|
||||
|
||||
order = new PromotionOrder();
|
||||
order.setExternalId(ORDER_ID);
|
||||
order.setAmount(new BigDecimal("49.99"));
|
||||
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
|
||||
}
|
||||
|
||||
private String itn(String amount, String currency, String status) {
|
||||
String hash = autoPayService.hash(cfg, List.of(
|
||||
SERVICE_ID, ORDER_ID, "remote-1", amount, currency, "21", "20260827120000", status, "OK"));
|
||||
return encode(xml(amount, currency, status, hash));
|
||||
}
|
||||
|
||||
private String xml(String amount, String currency, String status, String hash) {
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
|
||||
+ "<transactionList>"
|
||||
+ "<serviceID>" + SERVICE_ID + "</serviceID>"
|
||||
+ "<transactions><transaction>"
|
||||
+ "<orderID>" + ORDER_ID + "</orderID>"
|
||||
+ "<remoteID>remote-1</remoteID>"
|
||||
+ "<amount>" + amount + "</amount>"
|
||||
+ "<currency>" + currency + "</currency>"
|
||||
+ "<gatewayID>21</gatewayID>"
|
||||
+ "<paymentDate>20260827120000</paymentDate>"
|
||||
+ "<paymentStatus>" + status + "</paymentStatus>"
|
||||
+ "<paymentStatusDetails>OK</paymentStatusDetails>"
|
||||
+ "</transaction></transactions>"
|
||||
+ "<hash>" + hash + "</hash>"
|
||||
+ "</transactionList>";
|
||||
}
|
||||
|
||||
private static String encode(String xml) {
|
||||
return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String sha256(String input) throws Exception {
|
||||
byte[] d = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : d) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashFollowsDocumentationVector() throws Exception {
|
||||
PaymentConfig doc = new PaymentConfig();
|
||||
doc.setSecretKey("2test2");
|
||||
assertThat(autoPayService.hash(doc, List.of("2", "100", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
|
||||
// Puste pole opcjonalne pomijane razem z separatorem.
|
||||
assertThat(autoPayService.hash(doc, List.of("2", "100", "", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmsValidSuccessNotification() {
|
||||
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
|
||||
|
||||
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
|
||||
verify(promotionService).applyPaid(order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForgedSignature() {
|
||||
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", "0000000000000000")));
|
||||
|
||||
assertThat(response).contains("<confirmation>NOTCONFIRMED</confirmation>");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSignatureFromDifferentKey() {
|
||||
PaymentConfig attacker = new PaymentConfig();
|
||||
attacker.setSecretKey("klucz-atakujacego");
|
||||
String hash = autoPayService.hash(attacker, List.of(
|
||||
SERVICE_ID, ORDER_ID, "remote-1", "49.99", "PLN", "21", "20260827120000", "SUCCESS", "OK"));
|
||||
|
||||
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", hash)));
|
||||
|
||||
assertThat(response).contains("NOTCONFIRMED");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingPaymentIsAcknowledgedButNotPaid() {
|
||||
// Regresja: PENDING byl traktowany jak oplacona platnosc.
|
||||
String response = service.handle(itn("49.99", "PLN", "PENDING"));
|
||||
|
||||
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPaymentMarksOrderFailed() {
|
||||
service.handle(itn("49.99", "PLN", "FAILURE"));
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.FAILED);
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnderpayment() {
|
||||
String response = service.handle(itn("0.01", "PLN", "SUCCESS"));
|
||||
|
||||
assertThat(response).contains("NOTCONFIRMED");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongCurrency() {
|
||||
String response = service.handle(itn("49.99", "EUR", "SUCCESS"));
|
||||
|
||||
assertThat(response).contains("NOTCONFIRMED");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isIdempotentOnRetry() {
|
||||
service.handle(itn("49.99", "PLN", "SUCCESS"));
|
||||
order.setStatus(OrderStatus.PAID); // applyPaid jest zamockowane - odwzorowujemy skutek
|
||||
|
||||
String second = service.handle(itn("49.99", "PLN", "SUCCESS"));
|
||||
|
||||
assertThat(second).contains("<confirmation>CONFIRMED</confirmation>");
|
||||
verify(promotionService, times(1)).applyPaid(order);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownOrderId() {
|
||||
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.empty());
|
||||
|
||||
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
|
||||
|
||||
assertThat(response).contains("NOTCONFIRMED");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNotificationsWhenGatewayIsNotConfigured() {
|
||||
String valid = itn("49.99", "PLN", "SUCCESS");
|
||||
cfg.setEnabled(false);
|
||||
|
||||
assertThat(service.handle(valid)).contains("NOTCONFIRMED");
|
||||
verify(promotionService, never()).applyPaid(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesXmlWithExternalEntities() {
|
||||
String xxe = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///etc/passwd\">]>"
|
||||
+ "<transactionList><serviceID>&e;</serviceID></transactionList>";
|
||||
|
||||
assertThatThrownBy(() -> service.handle(encode(xxe))).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package pl.polskalokalnie.promotion;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.payment.AutoPayService;
|
||||
import pl.polskalokalnie.payment.PaymentConfig;
|
||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
class PaymentSecurityTest {
|
||||
|
||||
private final PromotionPackageRepository packageRepository = mock(PromotionPackageRepository.class);
|
||||
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
|
||||
private final UserRepository userRepository = mock(UserRepository.class);
|
||||
private final PaymentConfigService paymentConfigService = mock(PaymentConfigService.class);
|
||||
private final PromotionPlanRepository planRepository = mock(PromotionPlanRepository.class);
|
||||
private final ListingRepository listingRepository = mock(ListingRepository.class);
|
||||
|
||||
private PromotionService service(boolean autoConfirm) {
|
||||
return new PromotionService(planRepository, packageRepository, orderRepository,
|
||||
userRepository, listingRepository, paymentConfigService, mock(AutoPayService.class),
|
||||
autoConfirm);
|
||||
}
|
||||
|
||||
private PropertyListing ownListing() {
|
||||
PropertyListing listing = new PropertyListing();
|
||||
listing.setOwnerEmail("user@example.com");
|
||||
when(listingRepository.findById(10L)).thenReturn(Optional.of(listing));
|
||||
return listing;
|
||||
}
|
||||
|
||||
private PromotionPlan plan(boolean active) {
|
||||
PromotionPlan plan = new PromotionPlan();
|
||||
plan.setName("Plan");
|
||||
plan.setDurationDays(30);
|
||||
plan.setPrice(new BigDecimal("89.99"));
|
||||
plan.setActive(active);
|
||||
when(planRepository.findById(3L)).thenReturn(Optional.of(plan));
|
||||
return plan;
|
||||
}
|
||||
|
||||
@Test
|
||||
void creditIsNotSpentTwice() {
|
||||
// Regresja: odczyt-sprawdzenie-zapis salda pozwalal rownoleglym zadaniom wydac 1 kredyt kilka razy.
|
||||
// Teraz o wydaniu decyduje warunkowy UPDATE w bazie - gdy nie zmieni wiersza, promowania nie ma.
|
||||
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
|
||||
ownListing();
|
||||
plan(true);
|
||||
when(userRepository.consumePromotionCredit(any())).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
|
||||
verify(listingRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactivePlanCannotBeBoughtOrPaidWithCredit() {
|
||||
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
|
||||
ownListing();
|
||||
plan(false);
|
||||
when(userRepository.consumePromotionCredit(any())).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
|
||||
verify(userRepository, never()).consumePromotionCredit(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
|
||||
// Regresja: bez skonfigurowanej bramki zamowienie bylo od razu oplacone - darmowe kredyty dla kazdego.
|
||||
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
|
||||
|
||||
assertThatThrownBy(() -> service(false).checkout("user@example.com",
|
||||
new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay")))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE));
|
||||
verify(orderRepository, never()).save(any());
|
||||
verify(userRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkoutWithoutGatewayConfirmsOnlyWhenAutoConfirmIsEnabled() {
|
||||
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
|
||||
PromotionPackage pack = new PromotionPackage();
|
||||
pack.setName("Pakiet");
|
||||
pack.setQuantity(5);
|
||||
pack.setPrice(new BigDecimal("199.99"));
|
||||
when(packageRepository.findById(any())).thenReturn(Optional.of(pack));
|
||||
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
AppUser user = new AppUser();
|
||||
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
service(true).checkout("user@example.com", new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay"));
|
||||
|
||||
verify(userRepository).addPromotionCredits(user.getId(), 5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paymentDescriptionUsesOnlyCharactersAllowedByGateway() {
|
||||
PromotionOrder order = new PromotionOrder();
|
||||
order.setKind(PromotionKind.PLAN);
|
||||
order.setListingId(42L);
|
||||
assertThat(PromotionService.paymentDescription(order))
|
||||
.isEqualTo("Promowanie ogloszenia nr 42")
|
||||
.matches("[A-Za-z0-9 .:,-]{1,79}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package pl.polskalokalnie.send;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||
|
||||
class MessageComposerTest {
|
||||
|
||||
private final UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
|
||||
private final MessageComposer composer = new MessageComposer(unsubscribeService, "https://polskalokalnie.pl");
|
||||
|
||||
@Test
|
||||
void nameInHtmlCampaignIsEscaped() {
|
||||
// Regresja: imie z publicznego formularza trafialo do maila kampanii jako HTML.
|
||||
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
|
||||
|
||||
String html = composer.composeEmail("<p>Dzień dobry {{name}}!</p>", "<a href=\"https://evil\">X</a>", 1L);
|
||||
|
||||
assertThat(html).doesNotContain("href=\"https://evil\"").contains("<a href="https://evil">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nameInPlainTextCampaignIsEscaped() {
|
||||
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
|
||||
|
||||
String html = composer.composeEmail("Dzień dobry {{name}}!", "<b>Jan</b>", 1L);
|
||||
|
||||
assertThat(html).doesNotContain("<b>Jan</b>").contains("<b>Jan</b>");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:polskalokalnie;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
# Testcontainers: przy pierwszym polaczeniu startuje tymczasowy PostgreSQL 16 (ta sama wersja co
|
||||
# produkcja w docker-compose), wspolny dla wszystkich testow i usuwany po ich zakonczeniu.
|
||||
# Wymaga uruchomionego Dockera przy "mvn test".
|
||||
url: jdbc:tc:postgresql:16-alpine:///polskalokalnie
|
||||
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
# Jak na produkcji. create-drop przy kazdym nowym kontekscie Springa kasowal tabele, z ktorych
|
||||
# korzystal juz dzialajacy kontekst; baza i tak jest swieza przy kazdym uruchomieniu testow.
|
||||
ddl-auto: update
|
||||
|
||||
app:
|
||||
jwt:
|
||||
|
||||
@@ -31,13 +31,16 @@ services:
|
||||
# Bez tych zmiennych backend bierze wartosci domyslne z application.yml, wiec login i haslo
|
||||
# administratora ustawione w .env nie mialy zadnego wplywu na dzialajaca aplikacje.
|
||||
APP_ADMIN_EMAIL: ${APP_ADMIN_EMAIL:-admin@softspm.com}
|
||||
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-Admin123!}
|
||||
# Bez wartosci domyslnej: brak hasla w .env.production oznacza, ze konto administratora nie
|
||||
# zostanie zalozone (blad w logu), zamiast powstac z jawnym haslem z repozytorium.
|
||||
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-}
|
||||
APP_ADMIN_NAME: ${APP_ADMIN_NAME:-Administrator Polska Lokalnie}
|
||||
# true = przy starcie ustaw haslo administratora na APP_ADMIN_PASSWORD (odzyskanie dostepu).
|
||||
APP_ADMIN_RESET_PASSWORD: ${APP_ADMIN_RESET_PASSWORD:-false}
|
||||
# Sekret podpisujacy tokeny sesji. Domyslna wartosc jest jawna w repozytorium - w produkcji
|
||||
# musi byc wlasna, inaczej kazdy moze wystawic sobie token administratora.
|
||||
APP_JWT_SECRET: ${APP_JWT_SECRET:-local-dev-secret-change-me-please-32chars-min}
|
||||
# Sekret podpisujacy tokeny sesji - bez wartosci domyslnej. Gdyby .env.production sie nie
|
||||
# wczytal, wdrozenie ma sie zatrzymac, a nie wystartowac z jawnym sekretem z repozytorium
|
||||
# (z nim kazdy moglby wystawic sobie token administratora).
|
||||
APP_JWT_SECRET: ${APP_JWT_SECRET:?Brak APP_JWT_SECRET w .env.production}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost}
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -39,6 +39,9 @@ services:
|
||||
# musi byc wlasna, inaczej kazdy moze wystawic sobie token administratora.
|
||||
APP_JWT_SECRET: ${APP_JWT_SECRET:-local-dev-secret-change-me-please-32chars-min}
|
||||
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost}
|
||||
# Lokalnie promowanie dziala bez kluczy AutoPay (platnosc potwierdzana automatycznie).
|
||||
# docker-compose.prod.yml tej zmiennej nie przekazuje - na produkcji zawsze false.
|
||||
APP_PAYMENTS_AUTO_CONFIRM: ${APP_PAYMENTS_AUTO_CONFIRM:-true}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
+1003
-870
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ROUTES } from './routes';
|
||||
import { useAuth } from './auth';
|
||||
import { apiFetch, useAuth } from './auth';
|
||||
|
||||
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.');
|
||||
|
||||
@@ -65,6 +65,76 @@ export function ActivationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type AutoPayStartForm = {
|
||||
gatewayUrl: string;
|
||||
serviceId: string;
|
||||
orderId: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
customerEmail: string | null;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
// /platnosc/:orderId - przekierowanie do bramki AutoPay. Transakcje startuje sie formularzem POST,
|
||||
// wiec podpisane pola pobieramy z API i wysylamy automatycznie. Podpis nie trafia do paska adresu,
|
||||
// historii przegladarki ani naglowka Referer.
|
||||
export function PaymentRedirectPage() {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const [form, setForm] = useState<AutoPayStartForm | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const ran = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ran.current) {
|
||||
return;
|
||||
}
|
||||
ran.current = true;
|
||||
apiFetch<AutoPayStartForm>(`/promotion/order/${encodeURIComponent(orderId ?? '')}/autopay-form`)
|
||||
.then(setForm)
|
||||
.catch((e) => setError(errMsg(e)));
|
||||
}, [orderId]);
|
||||
|
||||
// Automatyczna wysylka formularza po pobraniu podpisanych pol.
|
||||
useEffect(() => {
|
||||
if (form) {
|
||||
formRef.current?.submit();
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AuthStatusCard>
|
||||
<div className="auth-status-icon err">!</div>
|
||||
<h1>Nie udało się rozpocząć płatności</h1>
|
||||
<p className="auth-status-lead">{error}</p>
|
||||
<Link className="auth-status-btn" to={ROUTES.accountListings}>Wróć do moich ofert</Link>
|
||||
</AuthStatusCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthStatusCard>
|
||||
<h1>Przekierowanie do płatności</h1>
|
||||
<p className="auth-status-lead">
|
||||
{form ? 'Za chwilę nastąpi przekierowanie do AutoPay...' : 'Przygotowujemy bezpieczną płatność...'}
|
||||
</p>
|
||||
{form && (
|
||||
<form ref={formRef} method="POST" action={form.gatewayUrl}>
|
||||
<input type="hidden" name="ServiceID" value={form.serviceId} />
|
||||
<input type="hidden" name="OrderID" value={form.orderId} />
|
||||
<input type="hidden" name="Amount" value={form.amount} />
|
||||
<input type="hidden" name="Description" value={form.description} />
|
||||
{form.customerEmail && <input type="hidden" name="CustomerEmail" value={form.customerEmail} />}
|
||||
<input type="hidden" name="Hash" value={form.hash} />
|
||||
{/* Zapasowy przycisk, gdyby automatyczna wysylka nie zadzialala. */}
|
||||
<button className="auth-status-btn" type="submit">Kontynuuj do płatności</button>
|
||||
</form>
|
||||
)}
|
||||
</AuthStatusCard>
|
||||
);
|
||||
}
|
||||
|
||||
// /reset-hasla/:token - ustawienie nowego hasla.
|
||||
export function PasswordResetPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
|
||||
@@ -217,7 +217,7 @@ export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
|
||||
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
|
||||
<h2>Bramka szybkich płatności - AutoPay</h2>
|
||||
</div>
|
||||
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych działa tryb sandbox (płatność potwierdzana automatycznie).</p>
|
||||
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn</p>
|
||||
<div className="mkt-config-grid">
|
||||
<label>ServiceID
|
||||
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
|
||||
|
||||
+10
-7
@@ -36,7 +36,8 @@ 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;
|
||||
searchPropertyType: string | null;
|
||||
searchBudgetMax: number | null;
|
||||
@@ -76,7 +77,6 @@ export type AuthUser = {
|
||||
contactPreference: ContactPreference;
|
||||
preferredLanguage: PreferredLanguage;
|
||||
nip: string | null;
|
||||
birthDate: string | null;
|
||||
verified: boolean;
|
||||
phoneVerified: boolean;
|
||||
blocked: boolean;
|
||||
@@ -130,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;
|
||||
@@ -260,7 +260,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
|
||||
apiFetch<RegisterPending>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, fullName, ...details }),
|
||||
// acceptTerms zapisuje na serwerze moment akceptacji Regulaminu i Polityki prywatnosci
|
||||
// (rozliczalnosc z art. 7 ust. 1 RODO) - checkbox w formularzu jest warunkiem wyslania.
|
||||
body: JSON.stringify({ email, password, fullName, acceptTerms: true, ...details }),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -298,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
// Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej.
|
||||
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
||||
await apiFetch<void>('/auth/change-password', {
|
||||
applyAuth(await apiFetch<AuthResponse>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}, []);
|
||||
}));
|
||||
}, [applyAuth]);
|
||||
|
||||
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
|
||||
const verifyPassword = useCallback(async (password: string) => {
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Schematyczne ilustracje ekranow serwisu dla strony /konto/pomoc.
|
||||
*
|
||||
* SWIADOMY WYBOR: to inline SVG, a NIE zrzuty ekranu. Rysuja uklad i etykiety ("gdzie co kliknac"),
|
||||
* nie piksel-perfect wyglad - dzieki temu nie dezaktualizuja sie przy zmianach stylu, sa lekkie
|
||||
* (zadnych plikow binarnych w repozytorium) i edytuje sie je jak zwykly kod. To samo rozwiazanie
|
||||
* co w Gieldzie Komunalnej (components/helpIllustrations.tsx).
|
||||
*
|
||||
* UTRZYMANIE: przy zmianie ukladu ekranu, nazw pol albo przebiegu poprawiaj ilustracje razem
|
||||
* z odpowiedzia w FAQ. Drobne zmiany kolorow czy odstepow w aplikacji ilustracji nie uniewazniaja.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Wspolna ramka: rysunek w ramce i podpis pod nim. */
|
||||
function Figure({ caption, children }: { caption: string; children: ReactNode }) {
|
||||
return (
|
||||
<figure className="help-figure">
|
||||
<div className="help-figure-frame">{children}</div>
|
||||
<figcaption>{caption}</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/* Klasy powtarzane w rysunkach - kolory siedza w styles.css, nie w atrybutach. */
|
||||
const BOX = 'hf-box';
|
||||
const LABEL = 'hf-label';
|
||||
const SMALL = 'hf-label-sm';
|
||||
const TITLE = 'hf-title';
|
||||
|
||||
/** Rejestracja: wybor rodzaju konta na starcie. */
|
||||
export function RegisterIllustration() {
|
||||
return (
|
||||
<Figure caption="Rejestracja - najpierw wybierasz rodzaj konta, potem wypełniasz dane.">
|
||||
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic rejestracji: dwa kafelki wyboru konta - Osoba prywatna i Firma, poniżej pola e-mail i hasło oraz zgoda na regulamin.">
|
||||
<text x="12" y="20" className={TITLE}>Załóż konto</text>
|
||||
<text x="12" y="40" className={LABEL}>Rodzaj konta</text>
|
||||
|
||||
<rect x="12" y="50" width="240" height="40" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<circle cx="32" cy="70" r="7" className="hf-dot-active" />
|
||||
<text x="48" y="74" className="hf-title hf-text-active">Osoba prywatna</text>
|
||||
|
||||
<rect x="268" y="50" width="240" height="40" rx="8" className={BOX} strokeWidth="1" />
|
||||
<circle cx="288" cy="70" r="7" className="hf-dot" />
|
||||
<text x="304" y="74" className={LABEL}>Firma (NIP z rejestru GUS)</text>
|
||||
|
||||
<rect x="12" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="119" className={SMALL}>Adres e-mail</text>
|
||||
<rect x="268" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="278" y="119" className={SMALL}>Hasło (min. 8 znaków)</text>
|
||||
|
||||
<rect x="12" y="138" width="14" height="14" rx="3" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="34" y="150" className={SMALL}>Akceptuję Regulamin i Politykę prywatności</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aktywacja konta: link z e-maila. */
|
||||
export function ActivationIllustration() {
|
||||
return (
|
||||
<Figure caption="Po rejestracji konto czeka na aktywację - klikasz link z wiadomości e-mail.">
|
||||
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic przebiegu aktywacji: rejestracja, wiadomość e-mail z linkiem, aktywne konto.">
|
||||
{[
|
||||
{ x: 12, title: '1. Rejestracja', sub: 'formularz na stronie' },
|
||||
{ x: 188, title: '2. E-mail', sub: 'link aktywacyjny' },
|
||||
{ x: 364, title: '3. Logowanie', sub: 'konto gotowe' },
|
||||
].map((step) => (
|
||||
<g key={step.title}>
|
||||
<rect x={step.x} y="28" width="144" height="60" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={step.x + 14} y="54" className={TITLE}>{step.title}</text>
|
||||
<text x={step.x + 14} y="72" className={SMALL}>{step.sub}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d="M162 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
<path d="M338 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logowanie wylacznie adresem e-mail. */
|
||||
export function LoginIllustration() {
|
||||
return (
|
||||
<Figure caption="Logujesz się adresem e-mail - nazwa użytkownika (nick) służy tylko do adresu profilu.">
|
||||
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic logowania: pole e-mail, pole hasła, odnośnik do odzyskiwania hasła i przycisk Zaloguj się.">
|
||||
<text x="12" y="22" className={TITLE}>Zaloguj się</text>
|
||||
<rect x="12" y="34" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="52" className={SMALL}>jan.kowalski@example.com</text>
|
||||
<rect x="12" y="70" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="88" className={SMALL}>••••••••</text>
|
||||
<text x="12" y="118" className="hf-label-sm hf-text-active">Nie pamiętam hasła</text>
|
||||
<rect x="358" y="34" width="150" height="64" rx="8" className="hf-fill-active" />
|
||||
<text x="398" y="72" className="hf-title hf-text-white">Zaloguj się</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularz dodawania ogloszenia i droga przez moderacje. */
|
||||
export function AddListingIllustration() {
|
||||
return (
|
||||
<Figure caption="Dodawanie ogłoszenia - wypełniasz formularz, a ogłoszenie czeka na akceptację obsługi.">
|
||||
<svg viewBox="0 0 520 180" role="img" aria-label="Szkic formularza ogłoszenia: rodzaj oferty, tytuł, cena, metraż i zdjęcia, obok ścieżka od wysłania do publikacji.">
|
||||
<text x="12" y="20" className={TITLE}>Dodaj ogłoszenie</text>
|
||||
|
||||
<rect x="12" y="32" width="120" height="30" rx="6" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="34" y="51" className="hf-title hf-text-active">Sprzedaż</text>
|
||||
<rect x="140" y="32" width="120" height="30" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="170" y="51" className={LABEL}>Wynajem</text>
|
||||
|
||||
<rect x="12" y="72" width="248" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="89" className={SMALL}>Tytuł ogłoszenia</text>
|
||||
<rect x="12" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="22" y="123" className={SMALL}>Cena</text>
|
||||
<rect x="140" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="150" y="123" className={SMALL}>Metraż</text>
|
||||
<rect x="12" y="140" width="248" height="28" rx="6" className={BOX} strokeWidth="1" strokeDasharray="4 3" />
|
||||
<text x="22" y="158" className={SMALL}>Zdjęcia - przeciągnij albo wybierz z dysku</text>
|
||||
|
||||
{[
|
||||
{ y: 36, title: 'Wysyłasz', sub: 'status: oczekuje' },
|
||||
{ y: 90, title: 'Moderacja', sub: 'sprawdzamy treść' },
|
||||
{ y: 144, title: 'Publikacja', sub: 'widoczne w ofertach' },
|
||||
].map((step) => (
|
||||
<g key={step.title}>
|
||||
<rect x="290" y={step.y - 20} width="218" height="40" rx="8" className={BOX} strokeWidth="1" />
|
||||
<circle cx="312" cy={step.y} r="8" className="hf-dot-active" />
|
||||
<text x="330" y={step.y - 2} className={TITLE}>{step.title}</text>
|
||||
<text x="330" y={step.y + 12} className={SMALL}>{step.sub}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Panel "Moje oferty" z akcjami przy ogloszeniu. */
|
||||
export function MyListingsIllustration() {
|
||||
return (
|
||||
<Figure caption="Moje oferty - przy każdym ogłoszeniu masz edycję, wstrzymanie, wyróżnienie i usunięcie.">
|
||||
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic listy Moje oferty: dwa wiersze ogłoszeń ze statusem oraz przyciskami Edytuj, Wstrzymaj i Wyróżnij.">
|
||||
<text x="12" y="20" className={TITLE}>Moje oferty</text>
|
||||
{[
|
||||
{ y: 34, status: 'Opublikowane', tone: 'hf-fill-ok' },
|
||||
{ y: 104, status: 'Oczekuje', tone: 'hf-fill-wait' },
|
||||
].map((row) => (
|
||||
<g key={row.status}>
|
||||
<rect x="12" y={row.y} width="496" height="56" rx="8" className={BOX} strokeWidth="1" />
|
||||
<rect x="22" y={row.y + 10} width="50" height="36" rx="4" className="hf-photo" />
|
||||
<rect x="84" y={row.y + 12} width="150" height="9" rx="3" className="hf-line" />
|
||||
<rect x="84" y={row.y + 28} width="78" height="14" rx="4" className={row.tone} />
|
||||
<text x="92" y={row.y + 39} className="hf-label-sm hf-text-white">{row.status}</text>
|
||||
<text x="250" y={row.y + 25} className="hf-title hf-text-active">690 000 zł</text>
|
||||
<text x="250" y={row.y + 41} className={SMALL}>Gdańsk, 58 m²</text>
|
||||
{['Edytuj', 'Wstrzymaj', 'Wyróżnij'].map((action, index) => (
|
||||
<g key={action}>
|
||||
<rect x={358 + index * 52} y={row.y + 18} width="48" height="20" rx="4" className={BOX} strokeWidth="1" />
|
||||
<text x={364 + index * 52} y={row.y + 32} className={SMALL}>{action}</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wyroznienie ogloszenia i platnosc. */
|
||||
export function PromotionIllustration() {
|
||||
return (
|
||||
<Figure caption="Wyróżnienie - wybierasz pakiet, płacisz przez AutoPay albo zużywasz kredyt z pakietu.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyróżniania: trzy pakiety do wyboru, poniżej ścieżka płatności AutoPay i powrót do serwisu.">
|
||||
<text x="12" y="20" className={TITLE}>Wyróżnij ogłoszenie</text>
|
||||
{/* Bez konkretnych kwot - cennik ustawia administrator i moze sie zmienic,
|
||||
a rysunek ma pokazywac uklad ekranu, nie aktualna oferte. */}
|
||||
{[
|
||||
{ x: 12, name: 'Krótkie', active: true },
|
||||
{ x: 184, name: 'Średnie', active: false },
|
||||
{ x: 356, name: 'Długie', active: false },
|
||||
].map((plan) => (
|
||||
<g key={plan.name}>
|
||||
<rect
|
||||
x={plan.x}
|
||||
y="32"
|
||||
width="152"
|
||||
height="52"
|
||||
rx="8"
|
||||
className={plan.active ? 'hf-box hf-active' : BOX}
|
||||
strokeWidth={plan.active ? 2 : 1}
|
||||
/>
|
||||
<text x={plan.x + 16} y="54" className={TITLE}>{plan.name} wyróżnienie</text>
|
||||
<text x={plan.x + 16} y="72" className={plan.active ? 'hf-label-sm hf-text-active' : SMALL}>liczba dni i cena z cennika</text>
|
||||
</g>
|
||||
))}
|
||||
{[
|
||||
{ x: 12, label: 'Wybór pakietu' },
|
||||
{ x: 188, label: 'AutoPay - płatność' },
|
||||
{ x: 364, label: 'Ogłoszenie na górze listy' },
|
||||
].map((step) => (
|
||||
<g key={step.label}>
|
||||
<rect x={step.x} y="102" width="144" height="34" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={step.x + 12} y="124" className={SMALL}>{step.label}</text>
|
||||
</g>
|
||||
))}
|
||||
<path d="M162 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
<path d="M338 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wyszukiwanie z filtrami i zapisem wyszukiwania. */
|
||||
export function SearchIllustration() {
|
||||
return (
|
||||
<Figure caption="Wyszukiwanie - filtry siedzą w adresie strony, więc wynik da się odświeżyć i wysłać znajomemu.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyszukiwarki: pola lokalizacja, rodzaj, cena i metraż, przycisk Szukaj oraz przycisk Zapisz wyszukiwanie.">
|
||||
{[
|
||||
{ x: 12, w: 150, label: 'Lokalizacja', value: 'Gdańsk' },
|
||||
{ x: 170, w: 120, label: 'Rodzaj', value: 'Mieszkanie' },
|
||||
{ x: 298, w: 100, label: 'Cena do', value: '800 000' },
|
||||
].map((field) => (
|
||||
<g key={field.label}>
|
||||
<rect x={field.x} y="20" width={field.w} height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x={field.x + 12} y="38" className={SMALL}>{field.label}</text>
|
||||
<text x={field.x + 12} y="55" className={TITLE}>{field.value}</text>
|
||||
</g>
|
||||
))}
|
||||
<rect x="406" y="20" width="102" height="44" rx="8" className="hf-fill-active" />
|
||||
<text x="437" y="48" className="hf-title hf-text-white">Szukaj</text>
|
||||
|
||||
<rect x="12" y="80" width="190" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="30" y="100" className="hf-title hf-text-active">Zapisz wyszukiwanie</text>
|
||||
<text x="216" y="100" className={SMALL}>Nowe pasujące oferty trafią na listę powiadomień.</text>
|
||||
|
||||
<text x="12" y="132" className={SMALL}>Adres: /kupno?miasto=Gdańsk&typ=Mieszkanie&cena_do=800000</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Alerty cenowe - gdzie sie je zaklada i gdzie ladują. */
|
||||
export function PriceAlertIllustration() {
|
||||
return (
|
||||
<Figure caption="Alerty cenowe - zakładasz je przy ofercie albo w Historii cen, a zmiany trafiają na listę powiadomień.">
|
||||
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic alertów: przycisk przy ofercie, przycisk w historii cen oraz powiadomienie o zmianie ceny.">
|
||||
<rect x="12" y="22" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="42" className={SMALL}>Karta oferty</text>
|
||||
<text x="26" y="58" className="hf-title hf-text-active">Utwórz alert ceny</text>
|
||||
|
||||
<rect x="12" y="78" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="98" className={SMALL}>Historia cen</text>
|
||||
<text x="26" y="114" className="hf-title hf-text-active">Utwórz alert cenowy</text>
|
||||
|
||||
<rect x="268" y="40" width="240" height="64" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<circle cx="292" cy="62" r="9" className="hf-dot-active" />
|
||||
<text x="310" y="60" className={TITLE}>Cena spadła: Gdańsk, Wrzeszcz</text>
|
||||
<text x="310" y="78" className={SMALL}>690 000 zł → 659 000 zł (-4,5%)</text>
|
||||
<text x="310" y="94" className={SMALL}>Lista powiadomień w serwisie</text>
|
||||
|
||||
<path d="M240 62 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wiadomosci i umawianie ogladania. */
|
||||
export function MessagesIllustration() {
|
||||
return (
|
||||
<Figure caption="Wiadomości - rozmowa dotyczy konkretnego ogłoszenia, a termin oglądania proponujesz w tym samym oknie.">
|
||||
<svg viewBox="0 0 520 160" role="img" aria-label="Szkic wiadomości: lista rozmów po lewej, rozmowa po prawej z przyciskiem proponowania terminu oglądania.">
|
||||
<rect x="12" y="16" width="150" height="130" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="24" y="36" className={TITLE}>Rozmowy</text>
|
||||
{[52, 84, 116].map((y, index) => (
|
||||
<g key={y}>
|
||||
<rect x="22" y={y} width="130" height="24" rx="6" className={index === 0 ? 'hf-box hf-active' : 'hf-photo'} strokeWidth={index === 0 ? 2 : 0} />
|
||||
<text x="32" y={y + 16} className={SMALL}>Mieszkanie {index + 1}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<rect x="174" y="16" width="334" height="130" rx="8" className={BOX} strokeWidth="1" />
|
||||
<rect x="186" y="30" width="180" height="22" rx="8" className="hf-photo" />
|
||||
<text x="196" y="45" className={SMALL}>Czy mieszkanie jest dostępne?</text>
|
||||
<rect x="316" y="60" width="180" height="22" rx="8" className="hf-fill-active" />
|
||||
<text x="326" y="75" className="hf-label-sm hf-text-white">Tak, zapraszam na oglądanie</text>
|
||||
|
||||
<rect x="186" y="98" width="200" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
|
||||
<text x="202" y="117" className="hf-title hf-text-active">Zaproponuj termin oglądania</text>
|
||||
<text x="396" y="117" className={SMALL}>Trafi do kalendarza</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ustawienia powiadomien. */
|
||||
export function NotificationSettingsIllustration() {
|
||||
return (
|
||||
<Figure caption="Ustawienia konta - trzy przełączniki decydują, co trafia na listę powiadomień.">
|
||||
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic ustawień powiadomień: przełączniki dla zapisanych wyszukiwań, alertów cenowych i wiadomości oraz osobna sekcja zgód marketingowych.">
|
||||
<text x="12" y="20" className={TITLE}>Powiadomienia</text>
|
||||
{[
|
||||
{ y: 32, label: 'Nowe oferty z zapisanych wyszukiwań', on: true },
|
||||
{ y: 62, label: 'Alerty cenowe', on: true },
|
||||
{ y: 92, label: 'Wiadomości i terminy oglądania', on: true },
|
||||
].map((row) => (
|
||||
<g key={row.label}>
|
||||
<rect x="12" y={row.y} width="496" height="26" rx="6" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y={row.y + 17} className={LABEL}>{row.label}</text>
|
||||
<rect x="452" y={row.y + 6} width="34" height="16" rx="8" className={row.on ? 'hf-fill-active' : 'hf-photo'} />
|
||||
<circle cx={row.on ? 478 : 460} cy={row.y + 14} r="6" className="hf-knob" />
|
||||
</g>
|
||||
))}
|
||||
<text x="12" y="138" className={SMALL}>Niżej, pod kreską: zgody marketingowe (newsletter i SMS) - osobna sprawa.</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usuniecie konta i zakres kasowanych danych. */
|
||||
export function AccountDeletionIllustration() {
|
||||
return (
|
||||
<Figure caption="Usunięcie konta - kasujemy dane i treści, zostają tylko dokumenty wymagane przepisami.">
|
||||
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic usuwania konta: lista kasowanych danych po lewej i lista danych zachowywanych po prawej.">
|
||||
<rect x="12" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="26" y="36" className={TITLE}>Kasujemy</text>
|
||||
<text x="26" y="56" className={SMALL}>ogłoszenia, wiadomości, ulubione,</text>
|
||||
<text x="26" y="72" className={SMALL}>zapisane wyszukiwania, alerty,</text>
|
||||
<text x="26" y="88" className={SMALL}>terminy oglądania, dane profilu</text>
|
||||
|
||||
<rect x="268" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
|
||||
<text x="282" y="36" className={TITLE}>Zostaje</text>
|
||||
<text x="282" y="56" className={SMALL}>rozliczenia płatności (5 lat),</text>
|
||||
<text x="282" y="72" className={SMALL}>zgłoszenia i moderacja (do 3 lat)</text>
|
||||
<text x="282" y="88" className={SMALL}>- wymagają tego przepisy</text>
|
||||
</svg>
|
||||
</Figure>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
+618
-156
@@ -1,6 +1,8 @@
|
||||
// Strony prawne serwisu: Regulamin, Polityka prywatności oraz baner zgody na pliki cookie.
|
||||
// Treść dostosowana do usług portalu Polska Lokalnie (ogłoszenia nieruchomości bez prowizji,
|
||||
// płatne promowanie ofert przez AutoPay). Dostawca: SoftSPM, hosting: OVHcloud.
|
||||
// Strony prawne serwisu: Regulamin, Polityka prywatnosci oraz baner zgody.
|
||||
//
|
||||
// ZASADA: dokumenty opisuja wylacznie to, co serwis naprawde robi. Kazda wymieniona tu funkcja,
|
||||
// kazdy odbiorca danych i kazdy okres przechowywania ma pokrycie w kodzie. Gdy zmienia sie
|
||||
// funkcjonalnosc albo lista uslug zewnetrznych, te pliki trzeba poprawic razem z nia.
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ROUTES } from './routes';
|
||||
@@ -12,202 +14,661 @@ const PROVIDER = {
|
||||
nip: '7691956407',
|
||||
regon: '100629083',
|
||||
email: 'info@polskalokalnie.pl',
|
||||
phone: '+48 453 193 605',
|
||||
hosting: 'OVHcloud (OVH Sp. z o.o., ul. Swobodna 1, 50-088 Wrocław)',
|
||||
dataCenter: 'centrum danych w Warszawie',
|
||||
};
|
||||
|
||||
const SERVICE_NAME = 'Polska Lokalnie';
|
||||
const LAST_UPDATED = '9 sierpnia 2026 r.';
|
||||
const LAST_UPDATED = '25 września 2026 r.';
|
||||
|
||||
function LegalPage({ title, subtitle, children }: { title: string; subtitle: string; children: ReactNode }) {
|
||||
type LegalSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wspolny uklad stron prawnych: ta sama szerokosc i ten sam jezyk wizualny co reszta serwisu
|
||||
* (/kupno, /jak-sprzedawac), plus spis tresci przyklejony z boku - dokumenty sa dlugie,
|
||||
* a uzytkownik zwykle szuka jednej konkretnej sekcji.
|
||||
*/
|
||||
function LegalDocument({ title, subtitle, intro, sections }: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
intro: ReactNode;
|
||||
sections: LegalSection[];
|
||||
}) {
|
||||
return (
|
||||
<section className="legal-page" aria-label={title}>
|
||||
<div className="legal-shell">
|
||||
<header className="legal-head">
|
||||
<p className="legal-brand">Polska Lokalnie <span>|</span> SoftSPM</p>
|
||||
<div className="legal-container">
|
||||
{/* Bez okruszkow - w projekcie kazdy widok zaczyna sie od naglowka H1 (patrz styles.css). */}
|
||||
<header className="legal-title">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
<p className="legal-updated">Data ostatniej aktualizacji: {LAST_UPDATED}</p>
|
||||
<div className="legal-meta">
|
||||
<span><strong>{SERVICE_NAME}</strong> · {PROVIDER.name}</span>
|
||||
<span>Obowiązuje od {LAST_UPDATED}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="legal-body">{children}</div>
|
||||
|
||||
<div className="legal-layout">
|
||||
<aside className="legal-toc" aria-label="Spis treści">
|
||||
<h2>Spis treści</h2>
|
||||
<ol>
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}><a href={`#${section.id}`}>{section.title}</a></li>
|
||||
))}
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<div className="legal-main">
|
||||
<div className="legal-card legal-intro">{intro}</div>
|
||||
{sections.map((section) => (
|
||||
<article className="legal-card" id={section.id} key={section.id}>
|
||||
<h2>{section.title}</h2>
|
||||
{section.body}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TermsPage() {
|
||||
function ProviderDetails() {
|
||||
return (
|
||||
<LegalPage
|
||||
title="Regulamin platformy Polska Lokalnie"
|
||||
subtitle="Zasady korzystania z platformy ogłoszeniowej oraz świadczenia usług drogą elektroniczną."
|
||||
>
|
||||
<h2>§ 1. Postanowienia ogólne</h2>
|
||||
<ol>
|
||||
<li>Niniejszy Regulamin określa zasady korzystania z platformy internetowej <strong>{SERVICE_NAME}</strong>, dostępnej pod adresem polskalokalnie.pl (dalej „Platforma").</li>
|
||||
<li>Właścicielem i dostawcą Platformy (Usługodawcą) jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}, REGON: {PROVIDER.regon}, adres e-mail: {PROVIDER.email}.</li>
|
||||
<li>Platforma jest utrzymywana na serwerach dostawcy hostingu {PROVIDER.hosting}, które są fizycznie zlokalizowane na terytorium Rzeczypospolitej Polskiej (w Unii Europejskiej).</li>
|
||||
<li>Regulamin jest udostępniany nieodpłatnie w sposób umożliwiający jego pozyskanie, odtworzenie i utrwalenie.</li>
|
||||
<li>Korzystanie z Platformy oznacza akceptację postanowień Regulaminu oraz Polityki prywatności.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 2. Definicje</h2>
|
||||
<ul>
|
||||
<li><strong>Usługodawca</strong> - {PROVIDER.name}, podmiot prowadzący Platformę.</li>
|
||||
<li><strong>Użytkownik</strong> - osoba fizyczna, osoba prawna lub jednostka organizacyjna korzystająca z Platformy.</li>
|
||||
<li><strong>Konsument</strong> - Użytkownik będący osobą fizyczną dokonujący czynności niezwiązanej bezpośrednio z jego działalnością gospodarczą lub zawodową.</li>
|
||||
<li><strong>Konto</strong> - zbiór zasobów i uprawnień przypisanych Użytkownikowi po rejestracji.</li>
|
||||
<li><strong>Ogłoszenie</strong> - zamieszczana przez Użytkownika oferta sprzedaży lub najmu nieruchomości.</li>
|
||||
<li><strong>Usługa promowania (Wyróżnienie)</strong> - odpłatna usługa zwiększająca widoczność Ogłoszenia.</li>
|
||||
<li><strong>Operator płatności</strong> - dostawca usług szybkich płatności obsługujący płatności w Platformie (AutoPay - Autopay S.A.).</li>
|
||||
<ul className="legal-facts">
|
||||
<li><span>Usługodawca</span><strong>{PROVIDER.legal}</strong></li>
|
||||
<li><span>Adres</span><strong>{PROVIDER.address}</strong></li>
|
||||
<li><span>NIP</span><strong>{PROVIDER.nip}</strong></li>
|
||||
<li><span>REGON</span><strong>{PROVIDER.regon}</strong></li>
|
||||
<li><span>E-mail</span><strong>{PROVIDER.email}</strong></li>
|
||||
<li><span>Telefon</span><strong>{PROVIDER.phone}</strong></li>
|
||||
<li><span>Hosting</span><strong>{PROVIDER.hosting}, {PROVIDER.dataCenter}</strong></li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
<h2>§ 3. Rodzaje i zakres usług</h2>
|
||||
export function TermsPage() {
|
||||
const sections: LegalSection[] = [
|
||||
{
|
||||
id: 'postanowienia-ogolne',
|
||||
title: '§ 1. Postanowienia ogólne',
|
||||
body: (
|
||||
<>
|
||||
<ol>
|
||||
<li>Platforma umożliwia w szczególności: przeglądanie i wyszukiwanie ogłoszeń nieruchomości, <strong>bezpłatne dodawanie ogłoszeń</strong> sprzedaży i najmu, prowadzenie Konta, zapisywanie ofert do ulubionych, ustawianie alertów cenowych, korzystanie z narzędzi (m.in. orientacyjna wycena, kalkulator zdolności), kontakt między Użytkownikami oraz odpłatne <strong>promowanie ogłoszeń</strong>.</li>
|
||||
<li>Dodawanie ogłoszeń jest bezpłatne i nie wiąże się z prowizją od transakcji.</li>
|
||||
<li>Usługi płatne (promowanie, pakiety promowań) są świadczone zgodnie z § 8 i cennikiem prezentowanym w Platformie.</li>
|
||||
<li>Narzędzia analityczne i wyceny mają charakter wyłącznie poglądowy i nie stanowią oferty, porady ani operatu szacunkowego w rozumieniu przepisów prawa.</li>
|
||||
<li>Regulamin określa zasady korzystania z platformy <strong>{SERVICE_NAME}</strong>, dostępnej pod adresem polskalokalnie.pl (dalej „Platforma").</li>
|
||||
<li>Usługodawcą jest:</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 4. Wymagania techniczne</h2>
|
||||
<ProviderDetails />
|
||||
<ol start={3}>
|
||||
<li>Platforma działa na serwerach {PROVIDER.hosting}, w {PROVIDER.dataCenter}, na terytorium Rzeczypospolitej Polskiej.</li>
|
||||
<li>Usługodawca świadczy usługę hostingu treści: udostępnia narzędzia, a treść ogłoszeń i wiadomości pochodzi od Użytkowników.</li>
|
||||
<li>Regulamin jest udostępniany nieodpłatnie w sposób umożliwiający jego pozyskanie, odtworzenie i utrwalenie.</li>
|
||||
<li>Korzystanie z Platformy oznacza akceptację Regulaminu oraz <Link to={ROUTES.privacy}>Polityki prywatności</Link>.</li>
|
||||
</ol>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'definicje',
|
||||
title: '§ 2. Definicje',
|
||||
body: (
|
||||
<ul>
|
||||
<li><strong>Użytkownik</strong> - osoba fizyczna, prawna lub jednostka organizacyjna korzystająca z Platformy.</li>
|
||||
<li><strong>Konsument</strong> - Użytkownik będący osobą fizyczną, dokonujący czynności niezwiązanej bezpośrednio z działalnością gospodarczą lub zawodową.</li>
|
||||
<li><strong>Konto</strong> - zbiór zasobów i uprawnień przypisanych Użytkownikowi po rejestracji i aktywacji.</li>
|
||||
<li><strong>Ogłoszenie</strong> - zamieszczana przez Użytkownika oferta sprzedaży lub najmu nieruchomości.</li>
|
||||
<li><strong>Wyróżnienie</strong> - odpłatna usługa zwiększająca widoczność Ogłoszenia na listach wyników.</li>
|
||||
<li><strong>Operator płatności</strong> - Autopay S.A. z siedzibą w Sopocie, obsługujący płatności za usługi odpłatne.</li>
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zakres-uslug',
|
||||
title: '§ 3. Zakres usług',
|
||||
body: (
|
||||
<>
|
||||
<p>W ramach Platformy Użytkownik może korzystać z następujących funkcji:</p>
|
||||
<ol>
|
||||
<li>Do korzystania z Platformy niezbędne są: urządzenie z dostępem do Internetu, aktualna przeglądarka internetowa z obsługą JavaScript i plików cookie oraz aktywne konto poczty elektronicznej.</li>
|
||||
<li>Usługodawca nie ponosi odpowiedzialności za problemy techniczne leżące po stronie Użytkownika lub dostawców usług telekomunikacyjnych.</li>
|
||||
<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. 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>
|
||||
<li><strong>Narzędzia informacyjne</strong> - historia cen (na podstawie danych Narodowego Banku Polskiego, Głównego Urzędu Statystycznego oraz ogłoszeń w Platformie), orientacyjna wycena nieruchomości, analiza „ile można utargować", ranking dzielnic i kalkulator zdolności kredytowej.</li>
|
||||
<li><strong>Poradniki</strong> - czytanie artykułów oraz publikowanie własnych, po akceptacji przez obsługę serwisu.</li>
|
||||
<li><strong>Wyróżnianie Ogłoszeń</strong> - usługa odpłatna, opisana w § 9.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 5. Rejestracja i Konto</h2>
|
||||
<p className="legal-note">
|
||||
Narzędzia informacyjne (wycena, historia cen, analiza negocjacji, ranking, kalkulator) mają charakter
|
||||
orientacyjny. Nie stanowią oferty, porady inwestycyjnej ani operatu szacunkowego w rozumieniu przepisów
|
||||
o gospodarce nieruchomościami.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'wymagania-techniczne',
|
||||
title: '§ 4. Wymagania techniczne',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Założenie Konta wymaga wypełnienia formularza rejestracji oraz akceptacji Regulaminu i Polityki prywatności. Możliwa jest również rejestracja i logowanie za pośrednictwem kont zewnętrznych (Google, Facebook).</li>
|
||||
<li>Użytkownik zobowiązuje się podać dane prawdziwe i aktualne oraz zabezpieczyć dane logowania przed dostępem osób trzecich.</li>
|
||||
<li>Konto jest przypisane do jednego Użytkownika; udostępnianie go osobom trzecim jest niedozwolone.</li>
|
||||
<li>Użytkownik może w każdej chwili usunąć Konto, kontaktując się z Usługodawcą lub korzystając z odpowiedniej opcji w Platformie.</li>
|
||||
<li>Do korzystania z Platformy potrzebne są: urządzenie z dostępem do Internetu, aktualna przeglądarka z obsługą JavaScript oraz czynne konto poczty elektronicznej.</li>
|
||||
<li>Platforma zapisuje dane w pamięci przeglądarki (localStorage). Jej zablokowanie uniemożliwia zalogowanie się.</li>
|
||||
<li>Usługodawca nie odpowiada za problemy wynikające z konfiguracji urządzenia Użytkownika lub działania jego dostawcy Internetu.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 6. Zasady publikacji ogłoszeń</h2>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'konto',
|
||||
title: '§ 5. Rejestracja i Konto',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Ogłoszenie może dotyczyć wyłącznie rzeczywistej oferty sprzedaży lub najmu nieruchomości, do której Użytkownik posiada tytuł prawny lub odpowiednie umocowanie.</li>
|
||||
<li>Treść Ogłoszenia powinna być zgodna ze stanem faktycznym, kompletna i nie może wprowadzać w błąd.</li>
|
||||
<li>Zabronione jest zamieszczanie treści bezprawnych, w szczególności: naruszających prawa osób trzecich, obraźliwych, wulgarnych, dyskryminujących, wprowadzających w błąd, o charakterze spamu lub niezwiązanych z przedmiotem Platformy.</li>
|
||||
<li>Ogłoszenia podlegają moderacji. Usługodawca może wstrzymać publikację, edytować w niezbędnym zakresie lub usunąć Ogłoszenie naruszające Regulamin lub przepisy prawa, informując o tym Użytkownika.</li>
|
||||
<li>Użytkownik udziela Usługodawcy niewyłącznej, nieodpłatnej licencji na korzystanie z treści i zdjęć zamieszczonych w Ogłoszeniu w zakresie niezbędnym do świadczenia usług (prezentacja i promocja Ogłoszenia w Platformie i kanałach powiązanych).</li>
|
||||
<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. 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. 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>
|
||||
</ol>
|
||||
|
||||
<h2>§ 7. Odpowiedzialność</h2>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ogloszenia',
|
||||
title: '§ 6. Zasady publikacji ogłoszeń',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Ogłoszenie może dotyczyć wyłącznie rzeczywistej oferty sprzedaży lub najmu nieruchomości, do której Użytkownik ma tytuł prawny albo stosowne umocowanie.</li>
|
||||
<li>Treść Ogłoszenia musi być zgodna ze stanem faktycznym, kompletna i nie może wprowadzać w błąd, w szczególności co do ceny, powierzchni i stanu nieruchomości.</li>
|
||||
<li>Do Ogłoszenia można dodać <strong>do 8 zdjęć</strong> oraz odnośnik do wirtualnego spaceru umieszczonego w serwisie zewnętrznym (YouTube, Matterport, Kuula).</li>
|
||||
<li>Użytkownik może ograniczyć publikowaną lokalizację do przybliżonego obszaru zamiast dokładnego punktu na mapie.</li>
|
||||
<li>Zabronione jest zamieszczanie treści bezprawnych, w szczególności naruszających prawa osób trzecich, obraźliwych, dyskryminujących, wprowadzających w błąd oraz treści o charakterze spamu.</li>
|
||||
<li>Ogłoszenia i artykuły podlegają moderacji przed publikacją. Usługodawca może wstrzymać publikację, wezwać do poprawienia treści albo usunąć materiał naruszający Regulamin lub przepisy prawa, informując o tym Użytkownika.</li>
|
||||
<li>Użytkownik udziela Usługodawcy niewyłącznej, nieodpłatnej licencji na korzystanie z treści i zdjęć Ogłoszenia w zakresie niezbędnym do jego prezentacji w Platformie oraz w podglądach linku udostępnianego w komunikatorach i serwisach społecznościowych.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zglaszanie-tresci',
|
||||
title: '§ 7. Zgłaszanie nielegalnych treści i moderacja',
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Usługodawca jest dostawcą usług hostingu w rozumieniu rozporządzenia (UE) 2022/2065 (akt o usługach
|
||||
cyfrowych, DSA) i stosuje poniższy tryb zgłaszania treści.
|
||||
</p>
|
||||
<ol>
|
||||
<li><strong>Jak zgłosić.</strong> Przy każdym Ogłoszeniu oraz w rozmowie z Użytkownikiem dostępny jest przycisk zgłoszenia. Zgłoszenie można też wysłać na adres {PROVIDER.email}. Ten sam adres jest punktem kontaktowym dla organów i dla Użytkowników.</li>
|
||||
<li><strong>Co podać.</strong> Wskazanie treści (odnośnik), wyjaśnienie, dlaczego jest bezprawna lub sprzeczna z Regulaminem, oraz dane kontaktowe zgłaszającego. Do zgłoszenia można dołączyć załączniki.</li>
|
||||
<li><strong>Rozpatrzenie.</strong> Zgłoszenia rozpatrujemy terminowo, rzetelnie, w sposób niearbitralny i obiektywny, z udziałem człowieka - decyzji nie podejmuje automat.</li>
|
||||
<li><strong>Decyzja.</strong> Możliwe rozstrzygnięcia to: pozostawienie treści, wezwanie do jej poprawienia, ukrycie lub usunięcie treści oraz zablokowanie Konta w razie poważnych lub powtarzających się naruszeń. O decyzji i jej powodach informujemy zgłaszającego oraz autora treści.</li>
|
||||
<li><strong>Odwołanie.</strong> Od decyzji przysługuje odwołanie w terminie 14 dni, na adres {PROVIDER.email}. Odwołanie rozpatruje osoba, która nie podejmowała decyzji pierwotnej. Użytkownik zachowuje prawo dochodzenia roszczeń przed sądem.</li>
|
||||
<li><strong>Nadużycia.</strong> Zgłoszenia oczywiście bezzasadne, składane masowo lub w złej wierze, mogą pozostać bez rozpoznania.</li>
|
||||
</ol>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'kontakt-miedzy-uzytkownikami',
|
||||
title: '§ 8. Kontakt między Użytkownikami',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Wiadomości służą wyłącznie sprawom związanym z Ogłoszeniem. Zabronione jest wykorzystywanie ich do rozsyłania ofert handlowych niezwiązanych z Ogłoszeniem.</li>
|
||||
<li>Użytkownik może zablokować rozmówcę; blokada wstrzymuje wymianę wiadomości w obie strony.</li>
|
||||
<li>Termin oglądania uzgodniony w rozmowie jest ustaleniem między Użytkownikami. Usługodawca nie jest jego stroną i nie odpowiada za jego dotrzymanie.</li>
|
||||
<li>Usługodawca zaleca ostrożność: nie należy przekazywać w wiadomościach danych logowania, numerów kart ani kodów autoryzacyjnych.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'wyroznianie',
|
||||
title: '§ 9. Wyróżnianie ogłoszeń (usługa odpłatna)',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Użytkownik może odpłatnie wyróżnić własne Ogłoszenie na czas określony zgodnie z planami dostępnymi w Platformie. Wyróżnione Ogłoszenia prezentowane są wyżej na listach wyników.</li>
|
||||
<li>Dostępny jest także zakup pakietu wyróżnień; zakupione jednostki pozwalają wyróżnić dowolne własne Ogłoszenie.</li>
|
||||
<li>Ceny podawane w Platformie są cenami brutto w złotych polskich.</li>
|
||||
<li>Wyróżnienie aktywuje się po otrzymaniu potwierdzenia płatności od Operatora płatności i trwa przez wybrany okres. Zamknięcie przeglądarki po dokonaniu płatności nie wpływa na aktywację.</li>
|
||||
<li>Dokument potwierdzający zakup Usługodawca wystawia na żądanie Użytkownika zgłoszone na adres {PROVIDER.email}.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'platnosci',
|
||||
title: '§ 10. Płatności',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Płatności obsługuje <strong>Autopay S.A.</strong>, ul. Powstańców Warszawy 6, 81-718 Sopot - krajowa instytucja płatnicza nadzorowana przez Komisję Nadzoru Finansowego. Dostępne metody to m.in. BLIK, karta płatnicza i szybki przelew.</li>
|
||||
<li>Po wybraniu płatności Użytkownik zostaje przekierowany na stronę Operatora płatności. Usługodawca nie otrzymuje i nie przechowuje danych karty płatniczej ani danych logowania do bankowości.</li>
|
||||
<li>Potwierdzenie płatności Operator przekazuje automatycznie; na jego podstawie usługa jest aktywowana.</li>
|
||||
<li>W razie niepowodzenia płatności usługa nie zostaje uruchomiona, a Użytkownik może ponowić próbę. Jeżeli mimo obciążenia usługa nie została aktywowana, należy zgłosić to na adres {PROVIDER.email} - sprawa zostanie wyjaśniona z Operatorem płatności, a środki zwrócone.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'odstapienie',
|
||||
title: '§ 11. Odstąpienie od umowy (Konsument)',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Konsument może odstąpić od umowy zawartej na odległość w terminie 14 dni bez podania przyczyny.</li>
|
||||
<li>Usługa wyróżnienia rozpoczyna się przed upływem tego terminu wyłącznie na wyraźne żądanie Konsumenta. Po pełnym wykonaniu usługi prawo odstąpienia wygasa, a przy wykonaniu częściowym zwrot pomniejsza się proporcjonalnie do spełnionego świadczenia (art. 35 ustawy o prawach konsumenta).</li>
|
||||
<li>Oświadczenie o odstąpieniu można złożyć na adres {PROVIDER.email}. Zwrot następuje w terminie 14 dni, tym samym sposobem zapłaty.</li>
|
||||
<li>Odstąpienie od umowy zawartej z innym Użytkownikiem (np. umowy najmu lub sprzedaży nieruchomości) reguluje ta umowa i przepisy prawa - Usługodawca nie jest jej stroną.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'reklamacje',
|
||||
title: '§ 12. Reklamacje',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Reklamacje dotyczące usług Platformy można składać na adres {PROVIDER.email} lub przez formularz w zakładce Pomoc i kontakt, wskazując dane Użytkownika i opis zastrzeżeń.</li>
|
||||
<li>Reklamacje rozpatrujemy w terminie 14 dni i informujemy o wyniku na podany adres e-mail.</li>
|
||||
<li>Usługodawca odpowiada wobec Konsumenta za zgodność usługi cyfrowej z umową na zasadach określonych w ustawie o prawach konsumenta.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'odpowiedzialnosc',
|
||||
title: '§ 13. Odpowiedzialność',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Usługodawca udostępnia narzędzia teleinformatyczne i nie jest stroną transakcji zawieranych między Użytkownikami.</li>
|
||||
<li>Za treść Ogłoszeń, ich zgodność z prawem oraz przebieg i skutki transakcji odpowiada Użytkownik zamieszczający Ogłoszenie.</li>
|
||||
<li>Usługodawca dokłada starań, aby Platforma działała prawidłowo, jednak nie gwarantuje nieprzerwanej dostępności i zastrzega prawo do przerw technicznych.</li>
|
||||
<li>Usługodawca działa zgodnie z ustawą o świadczeniu usług drogą elektroniczną i nie ma obowiązku uprzedniej kontroli treści; po uzyskaniu wiarygodnej wiadomości o bezprawnym charakterze treści niezwłocznie uniemożliwia do niej dostęp.</li>
|
||||
<li>Za treść Ogłoszeń oraz przebieg i skutki transakcji odpowiada Użytkownik, który Ogłoszenie zamieścił.</li>
|
||||
<li>Usługodawca nie ma ogólnego obowiązku monitorowania treści. Po otrzymaniu wiarygodnej informacji o bezprawnym charakterze treści niezwłocznie uniemożliwia do niej dostęp, zgodnie z § 7.</li>
|
||||
<li>Usługodawca dokłada starań, aby Platforma działała bez przerw, lecz nie gwarantuje nieprzerwanej dostępności i zastrzega prawo do przerw technicznych.</li>
|
||||
<li>Powyższe ograniczenia nie wyłączają ani nie ograniczają praw Konsumenta w zakresie, w jakim przepisy bezwzględnie obowiązujące na to nie zezwalają.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 8. Promowanie ogłoszeń (usługi płatne)</h2>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'blokada-konta',
|
||||
title: '§ 14. Blokada i rozwiązanie umowy',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Użytkownik może odpłatnie wyróżnić Ogłoszenie na czas określony (np. 4, 14 lub 30 dni) zgodnie z planami dostępnymi w Platformie. Wyróżnione Ogłoszenia prezentowane są wyżej na listach wyników.</li>
|
||||
<li>Dostępny jest również zakup pakietu promowań (np. 5 lub 10 sztuk) w cenie promocyjnej; zakupione kredyty pozwalają wyróżnić dowolne własne Ogłoszenie zgodnie z wybranym planem czasowym.</li>
|
||||
<li>Ceny prezentowane w Platformie są cenami brutto wyrażonymi w złotych polskich (PLN).</li>
|
||||
<li>Usługa promowania rozpoczyna się niezwłocznie po zaksięgowaniu płatności i trwa przez wybrany okres.</li>
|
||||
<li>Usługodawca może zablokować Konto w razie: naruszenia Regulaminu lub prawa, zamieszczania treści bezprawnych, podszywania się pod inną osobę, działań zagrażających bezpieczeństwu Platformy albo zakładania kont w celu obejścia wcześniejszej blokady.</li>
|
||||
<li>O blokadzie i jej przyczynie informujemy na adres e-mail przypisany do Konta. Od decyzji przysługuje odwołanie na zasadach z § 7 ust. 5.</li>
|
||||
<li>Użytkownik może rozwiązać umowę w każdej chwili, usuwając Konto.</li>
|
||||
<li>Usługodawca może rozwiązać umowę z zachowaniem 14-dniowego terminu, z ważnych przyczyn, informując o tym Użytkownika.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 9. Płatności</h2>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bezpieczenstwo',
|
||||
title: '§ 15. Bezpieczeństwo i zgodność',
|
||||
body: (
|
||||
<>
|
||||
<ol>
|
||||
<li>Płatności za usługi płatne obsługiwane są przez zewnętrznego Operatora płatności (AutoPay - Autopay S.A.), umożliwiającego m.in. płatność BLIK, kartą płatniczą oraz szybkim przelewem.</li>
|
||||
<li>Rozliczenie następuje w momencie zakupu. Na żądanie Użytkownika Usługodawca wystawia dokument potwierdzający zakup zgodnie z obowiązującymi przepisami.</li>
|
||||
<li>Usługodawca nie przechowuje pełnych danych kart płatniczych - są one przetwarzane wyłącznie przez Operatora płatności w bezpiecznym środowisku.</li>
|
||||
<li>Usługodawca stosuje środki techniczne i organizacyjne odpowiadające ryzyku, opisane w <Link to={ROUTES.privacy}>Polityce prywatności</Link>.</li>
|
||||
<li>
|
||||
Zakres stosowania przepisów sektorowych: Platforma nie jest podmiotem finansowym ani dostawcą usług ICT
|
||||
dla podmiotów finansowych w rozumieniu rozporządzenia (UE) 2022/2554 (DORA), a ze względu na skalę
|
||||
działalności Usługodawca nie jest podmiotem kluczowym ani ważnym w rozumieniu dyrektywy (UE) 2022/2555
|
||||
(NIS2). Niezależnie od tego Usługodawca <strong>dobrowolnie stosuje praktyki bezpieczeństwa wzorowane
|
||||
na wymaganiach obu aktów</strong>: zarządzanie ryzykiem teleinformatycznym, kontrolę dostępu,
|
||||
szyfrowanie transmisji, ograniczanie liczby żądań oraz reagowanie na incydenty.
|
||||
</li>
|
||||
<li>Użytkownik zobowiązuje się nie podejmować działań zagrażających bezpieczeństwu Platformy, w szczególności prób nieuprawnionego dostępu, automatycznego pobierania danych na masową skalę ani zakłócania jej działania.</li>
|
||||
<li>Podatności bezpieczeństwa prosimy zgłaszać na adres {PROVIDER.email}. Zgłoszenia dokonane w dobrej wierze nie będą podstawą roszczeń wobec zgłaszającego.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 10. Odstąpienie od umowy (Konsument)</h2>
|
||||
<p className="legal-note">
|
||||
Platforma podlega w szczególności: rozporządzeniu (UE) 2016/679 (RODO), rozporządzeniu (UE) 2022/2065
|
||||
(DSA), ustawie o świadczeniu usług drogą elektroniczną, ustawie o prawach konsumenta, Kodeksowi cywilnemu
|
||||
oraz ustawie Prawo komunikacji elektronicznej.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'dane-osobowe',
|
||||
title: '§ 16. Dane osobowe',
|
||||
body: (
|
||||
<p>
|
||||
Zasady przetwarzania danych osobowych, listę odbiorców danych oraz okresy przechowywania określa
|
||||
<Link to={ROUTES.privacy}> Polityka prywatności</Link>. Administratorem danych jest Usługodawca.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'wlasnosc-intelektualna',
|
||||
title: '§ 17. Własność intelektualna',
|
||||
body: (
|
||||
<p>
|
||||
Platforma, jej układ, logotyp i oprogramowanie podlegają ochronie prawnej i stanowią własność Usługodawcy
|
||||
lub podmiotów, z którymi Usługodawca współpracuje. Kopiowanie lub wykorzystywanie ich bez zgody jest
|
||||
zabronione. Prawa do treści Ogłoszeń i artykułów pozostają przy ich autorach.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'spory',
|
||||
title: '§ 18. Pozasądowe rozwiązywanie sporów',
|
||||
body: (
|
||||
<>
|
||||
<p>Konsument może skorzystać z pozasądowych sposobów rozpatrywania reklamacji i dochodzenia roszczeń, w szczególności:</p>
|
||||
<ul>
|
||||
<li>pomocy powiatowego (miejskiego) rzecznika konsumentów,</li>
|
||||
<li>mediacji przy wojewódzkim inspektorze Inspekcji Handlowej,</li>
|
||||
<li>stałego sądu polubownego przy wojewódzkim inspektorze Inspekcji Handlowej,</li>
|
||||
<li>informacji i poradnictwa Urzędu Ochrony Konkurencji i Konsumentów (uokik.gov.pl).</li>
|
||||
</ul>
|
||||
<p className="legal-note">
|
||||
Europejska platforma ODR zakończyła działalność 20 lipca 2025 r., dlatego nie wskazujemy jej jako drogi
|
||||
rozwiązania sporu.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'postanowienia-koncowe',
|
||||
title: '§ 19. Zmiany Regulaminu i postanowienia końcowe',
|
||||
body: (
|
||||
<ol>
|
||||
<li>Konsument, który zawarł umowę na odległość, może od niej odstąpić bez podania przyczyny w terminie 14 dni.</li>
|
||||
<li>Rozpoczęcie świadczenia usługi promowania przed upływem terminu na odstąpienie następuje na wyraźne żądanie Konsumenta; Konsument przyjmuje do wiadomości, że po pełnym wykonaniu usługi traci prawo odstąpienia, a przy częściowym wykonaniu zwrot pomniejsza się proporcjonalnie do spełnionego świadczenia (art. 35 ustawy o prawach konsumenta).</li>
|
||||
<li>Oświadczenie o odstąpieniu można złożyć na adres e-mail: {PROVIDER.email}.</li>
|
||||
<li>Usługodawca może zmienić Regulamin z ważnych przyczyn, w szczególności zmiany przepisów lub zakresu usług, informując Użytkowników z co najmniej 14-dniowym wyprzedzeniem na adres e-mail przypisany do Konta.</li>
|
||||
<li>Użytkownik, który nie akceptuje zmian, może przed ich wejściem w życie usunąć Konto.</li>
|
||||
<li>W sprawach nieuregulowanych stosuje się przepisy prawa polskiego.</li>
|
||||
<li>Spory z Użytkownikiem niebędącym Konsumentem rozstrzyga sąd właściwy dla siedziby Usługodawcy.</li>
|
||||
<li>Jeżeli którekolwiek postanowienie Regulaminu okaże się nieważne, pozostałe zachowują moc.</li>
|
||||
</ol>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
<h2>§ 11. Reklamacje</h2>
|
||||
<ol>
|
||||
<li>Reklamacje dotyczące usług można składać na adres e-mail {PROVIDER.email}, wskazując dane Użytkownika i opis zastrzeżeń.</li>
|
||||
<li>Usługodawca rozpatruje reklamację w terminie do 14 dni i informuje Użytkownika o wyniku.</li>
|
||||
</ol>
|
||||
|
||||
<h2>§ 12. Dane osobowe</h2>
|
||||
<p>Zasady przetwarzania danych osobowych określa <Link to={ROUTES.privacy}>Polityka prywatności</Link>. Administratorem danych jest Usługodawca.</p>
|
||||
|
||||
<h2>§ 13. Pliki cookie</h2>
|
||||
<p>Platforma wykorzystuje pliki cookie zgodnie z Polityką prywatności. Podczas pierwszej wizyty Użytkownik wyraża zgodę na wykorzystanie plików cookie lub ogranicza ją do plików niezbędnych.</p>
|
||||
|
||||
<h2>§ 14. Prawa autorskie</h2>
|
||||
<p>Platforma, jej układ, logotyp oraz oprogramowanie podlegają ochronie prawnej i stanowią własność Usługodawcy lub podmiotów, z którymi Usługodawca współpracuje. Kopiowanie lub wykorzystywanie bez zgody jest zabronione.</p>
|
||||
|
||||
<h2>§ 15. Pozasądowe rozwiązywanie sporów</h2>
|
||||
<p>Konsument może skorzystać z pozasądowych sposobów rozpatrywania reklamacji i dochodzenia roszczeń, w tym z platformy ODR Komisji Europejskiej dostępnej pod adresem ec.europa.eu/consumers/odr oraz z pomocy powiatowego (miejskiego) rzecznika konsumentów.</p>
|
||||
|
||||
<h2>§ 16. Zmiany Regulaminu i postanowienia końcowe</h2>
|
||||
<ol>
|
||||
<li>Usługodawca może zmienić Regulamin z ważnych przyczyn (m.in. zmiana przepisów, zakresu usług), informując Użytkowników z odpowiednim wyprzedzeniem.</li>
|
||||
<li>W sprawach nieuregulowanych stosuje się przepisy prawa polskiego, w szczególności Kodeksu cywilnego, ustawy o świadczeniu usług drogą elektroniczną oraz ustawy o prawach konsumenta.</li>
|
||||
<li>Regulamin obowiązuje od dnia publikacji.</li>
|
||||
</ol>
|
||||
</LegalPage>
|
||||
return (
|
||||
<LegalDocument
|
||||
title="Regulamin"
|
||||
subtitle="Zasady korzystania z platformy ogłoszeniowej Polska Lokalnie i świadczenia usług drogą elektroniczną."
|
||||
intro={(
|
||||
<p>
|
||||
Regulamin opisuje funkcje, które Platforma rzeczywiście udostępnia, oraz zasady odpowiedzialności za treści
|
||||
publikowane przez Użytkowników. Dodawanie ogłoszeń jest bezpłatne i bez prowizji; odpłatne jest wyłącznie
|
||||
dobrowolne wyróżnianie ofert.
|
||||
</p>
|
||||
)}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivacyPolicyPage() {
|
||||
const sections: LegalSection[] = [
|
||||
{
|
||||
id: 'administrator',
|
||||
title: '1. Administrator danych',
|
||||
body: (
|
||||
<>
|
||||
<p>Administratorem danych osobowych jest:</p>
|
||||
<ProviderDetails />
|
||||
<p>
|
||||
We wszystkich sprawach dotyczących danych osobowych prosimy o kontakt na adres {PROVIDER.email}.
|
||||
Administrator nie wyznaczył inspektora ochrony danych - obowiązek taki nie wynika z art. 37 RODO.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'jakie-dane',
|
||||
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 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>
|
||||
<li><strong>Zgłoszenia i moderacja</strong> - treść zgłoszenia, załączniki oraz - przy zgłoszeniu Użytkownika - zapis rozmowy, której zgłoszenie dotyczy.</li>
|
||||
<li><strong>Płatności</strong> - identyfikator zamówienia, kwota, metoda i status płatności oraz adres e-mail płatnika. Nie otrzymujemy danych karty płatniczej.</li>
|
||||
<li><strong>Dane techniczne</strong> - adres IP, adres żądania, typ przeglądarki i strona odsyłająca, zapisywane w logach serwera.</li>
|
||||
<li><strong>Dane firmowe z rejestru</strong> - przy zakładaniu konta firmowego dane pobierane z rejestru REGON na podstawie podanego numeru NIP.</li>
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cele-i-podstawy',
|
||||
title: '3. Cele i podstawy prawne',
|
||||
body: (
|
||||
<ul>
|
||||
<li><strong>Prowadzenie konta i świadczenie usług</strong> (ogłoszenia, wiadomości, ulubione, alerty, terminy oglądania) - art. 6 ust. 1 lit. b RODO, czyli wykonanie umowy.</li>
|
||||
<li><strong>Obsługa płatności za wyróżnienia i rozliczenia</strong> - art. 6 ust. 1 lit. b i c RODO.</li>
|
||||
<li><strong>Moderacja treści i rozpatrywanie zgłoszeń</strong> - art. 6 ust. 1 lit. c RODO (obowiązki z aktu o usługach cyfrowych) oraz art. 6 ust. 1 lit. f RODO.</li>
|
||||
<li><strong>Bezpieczeństwo Platformy i zapobieganie nadużyciom</strong> - art. 6 ust. 1 lit. f RODO, czyli nasz prawnie uzasadniony interes.</li>
|
||||
<li><strong>Obrona przed roszczeniami i ich dochodzenie</strong> - art. 6 ust. 1 lit. f RODO.</li>
|
||||
<li><strong>Obowiązki rachunkowe i podatkowe</strong> - art. 6 ust. 1 lit. c RODO.</li>
|
||||
<li><strong>Informacje marketingowe</strong> (wiadomości o nowościach i ofertach) - wyłącznie na podstawie odrębnej zgody, art. 6 ust. 1 lit. a RODO. Zgody nie zakładamy z góry: domyślnie jest wyłączona, a Użytkownik włącza ją sam w ustawieniach konta i może wycofać w każdej chwili.</li>
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'czy-obowiazkowe',
|
||||
title: '4. Czy podanie danych jest obowiązkowe',
|
||||
body: (
|
||||
<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 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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'odbiorcy',
|
||||
title: '5. Odbiorcy danych',
|
||||
body: (
|
||||
<>
|
||||
<p>Dane powierzamy wyłącznie podmiotom, które przetwarzają je na nasze zlecenie, na podstawie umów powierzenia:</p>
|
||||
<ul>
|
||||
<li><strong>{PROVIDER.hosting}</strong> - utrzymanie serwerów w {PROVIDER.dataCenter}.</li>
|
||||
<li><strong>Dostawca poczty wychodzącej (SMTP)</strong> - wysyłka wiadomości systemowych, m.in. linku aktywacyjnego i powiadomień o wiadomościach.</li>
|
||||
<li><strong>Bramka SMS</strong> - wysyłka kodów potwierdzających numer telefonu.</li>
|
||||
</ul>
|
||||
<p>Odrębnymi administratorami danych są natomiast:</p>
|
||||
<ul>
|
||||
<li><strong>Autopay S.A.</strong> - w zakresie realizacji płatności za wyróżnienia.</li>
|
||||
<li><strong>Drugi Użytkownik</strong> - dane, które sam udostępniasz w ogłoszeniu lub w wiadomości (np. imię, numer telefonu, ustalony termin oglądania), trafiają do osoby, z którą się kontaktujesz.</li>
|
||||
</ul>
|
||||
<p>Wyświetlenie niektórych elementów strony powoduje połączenie Twojej przeglądarki z serwerami dostawców zewnętrznych, którzy otrzymują wówczas Twój adres IP:</p>
|
||||
<ul>
|
||||
<li><strong>Esri (ArcGIS Online)</strong> oraz <strong>OpenStreetMap</strong> - kafelki map. Mapa jest niezbędna do przeglądania ofert i ładuje się zawsze.</li>
|
||||
<li><strong>OpenStreetMap (Nominatim)</strong> - podpowiedzi adresów i ustalanie współrzędnych wpisanej lokalizacji. Do usługi trafia wpisywany adres.</li>
|
||||
<li><strong>flagcdn.com</strong> - obrazki flag przy wyborze kierunkowego numeru telefonu.</li>
|
||||
<li><strong>YouTube (tryb bez ciasteczek), Matterport, Kuula</strong> - wirtualne spacery osadzone w ogłoszeniach. Ładują się <strong>dopiero po Twoim kliknięciu</strong>; do tego czasu w ich miejscu widnieje zastępnik.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Dane mogą być także udostępnione organom publicznym, gdy obowiązek taki wynika z przepisów prawa.
|
||||
Nie sprzedajemy danych i nie udostępniamy ich partnerom marketingowym.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'poza-eog',
|
||||
title: '6. Przekazywanie danych poza EOG',
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Dane przechowujemy na serwerach w Polsce. Poza Europejski Obszar Gospodarczy trafia jedynie adres IP
|
||||
i informacje techniczne przeglądarki, w związku z wyświetlaniem elementów opisanych w punkcie 5:
|
||||
</p>
|
||||
<ul>
|
||||
<li>kafelki map dostarcza Esri z siedzibą w Stanach Zjednoczonych,</li>
|
||||
<li>wirtualne spacery - YouTube (Google), Matterport i Kuula, również ze Stanów Zjednoczonych, przy czym wyłącznie po Twoim kliknięciu.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Podstawą przekazania są mechanizmy przewidziane w rozdziale V RODO: decyzja Komisji Europejskiej
|
||||
stwierdzająca odpowiedni stopień ochrony (Data Privacy Framework) albo standardowe klauzule umowne.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'okresy',
|
||||
title: '7. Jak długo przechowujemy dane',
|
||||
body: (
|
||||
<ul>
|
||||
<li><strong>Dane konta i treści</strong> - przez czas posiadania konta. Po jego usunięciu kasujemy: wiadomości i rozmowy, ogłoszenia, ulubione, zapisane wyszukiwania, alerty cenowe, terminy oglądania, powiadomienia, artykuły, dane profilu oraz wpis w bazie marketingowej.</li>
|
||||
<li><strong>Dane rozliczeniowe</strong> (zamówienia wyróżnień) - 5 lat od końca roku, w którym powstał obowiązek podatkowy.</li>
|
||||
<li><strong>Zgłoszenia treści i korespondencja z obsługą</strong> - do 3 lat, w celu obrony przed roszczeniami i wykazania prawidłowej moderacji. Po usunięciu konta zgłoszenia pozostają w zakresie niezbędnym do tego celu.</li>
|
||||
<li><strong>Jednorazowe tokeny i kody SMS</strong> - usuwane automatycznie po 7 dniach od wygaśnięcia.</li>
|
||||
<li><strong>Logi serwera</strong> - przechowywane w buforze o ograniczonym rozmiarze i nadpisywane rotacyjnie; nie tworzymy z nich archiwum.</li>
|
||||
<li><strong>Dane przetwarzane na podstawie zgody</strong> - do czasu jej wycofania.</li>
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'prawa',
|
||||
title: '8. Twoje prawa',
|
||||
body: (
|
||||
<>
|
||||
<p>Przysługuje Ci prawo do:</p>
|
||||
<ul>
|
||||
<li>dostępu do danych i otrzymania ich kopii,</li>
|
||||
<li>sprostowania danych - większość zmienisz samodzielnie w ustawieniach konta,</li>
|
||||
<li>usunięcia danych - konto usuniesz samodzielnie w panelu konta,</li>
|
||||
<li>ograniczenia przetwarzania,</li>
|
||||
<li>przenoszenia danych przetwarzanych na podstawie umowy lub zgody,</li>
|
||||
<li><strong>sprzeciwu</strong> wobec przetwarzania opartego na prawnie uzasadnionym interesie,</li>
|
||||
<li>wycofania zgody w dowolnym momencie, bez wpływu na zgodność z prawem przetwarzania sprzed wycofania.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Przysługuje Ci także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych,
|
||||
ul. Stawki 2, 00-193 Warszawa, uodo.gov.pl.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'profilowanie',
|
||||
title: '9. Profilowanie i decyzje automatyczne',
|
||||
body: (
|
||||
<p>
|
||||
Nie profilujemy Użytkowników i nie podejmujemy wobec nich decyzji wyłącznie w sposób zautomatyzowany,
|
||||
które wywoływałyby skutki prawne lub podobnie istotnie na nich wpływały (art. 22 RODO). Alerty cenowe
|
||||
porównują wyłącznie liczby - cenę ogłoszenia lub średnią cenę za m² - i nie budują profilu osoby.
|
||||
Decyzje moderacyjne podejmuje człowiek; automat jedynie oznacza treści do sprawdzenia.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'pamiec-przegladarki',
|
||||
title: '10. Pamięć przeglądarki i pliki cookie',
|
||||
body: (
|
||||
<>
|
||||
<p>
|
||||
Platforma <strong>nie ustawia plików cookie</strong> i nie korzysta z narzędzi analitycznych ani
|
||||
pikseli reklamowych. Dane zapisujemy w pamięci przeglądarki (localStorage i sessionStorage):
|
||||
</p>
|
||||
<div className="legal-table-wrap">
|
||||
<table className="legal-table">
|
||||
<thead>
|
||||
<tr><th>Nazwa</th><th>Do czego służy</th><th>Jak długo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>polskalokalnie-auth-token</td>
|
||||
<td>utrzymanie zalogowania (niezbędne)</td>
|
||||
<td>24 godziny</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>polskalokalnie-cookie-consent</td>
|
||||
<td>zapamiętanie Twojego wyboru w banerze</td>
|
||||
<td>do wyczyszczenia danych strony</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>polskalokalnie.valuationDraft</td>
|
||||
<td>roboczy formularz wyceny, żeby nie ginął przy odświeżeniu</td>
|
||||
<td>do zamknięcia karty</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>accountProfile:<konto>:…</td>
|
||||
<td>drobne wygody interfejsu przypisane do konta: ostatnio oglądane oferty, wybrana zakładka, układ kafelków panelu</td>
|
||||
<td>do wyczyszczenia danych strony</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
Wybór w banerze decyduje o ładowaniu treści osadzanych z serwisów zewnętrznych (wirtualne spacery).
|
||||
Możesz go zmienić w każdej chwili, klikając „Ustawienia prywatności" w stopce. Osadzony spacer możesz
|
||||
też włączyć pojedynczo, przyciskiem przy konkretnym ogłoszeniu.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bezpieczenstwo-danych',
|
||||
title: '11. Bezpieczeństwo danych',
|
||||
body: (
|
||||
<>
|
||||
<p>Stosujemy środki techniczne i organizacyjne odpowiadające ryzyku, w szczególności:</p>
|
||||
<ul>
|
||||
<li>szyfrowanie transmisji (HTTPS z wymuszonym HSTS),</li>
|
||||
<li>przechowywanie haseł wyłącznie w postaci skrótu (bcrypt) - nie znamy Twojego hasła,</li>
|
||||
<li>kontrolę dostępu i rozdzielenie uprawnień w panelu administracyjnym,</li>
|
||||
<li>ograniczanie liczby żądań, chroniące przed atakami automatycznymi i nadużyciami,</li>
|
||||
<li>nagłówki bezpieczeństwa przeglądarki oraz oczyszczanie treści publikowanych przez Użytkowników,</li>
|
||||
<li>bieżącą aktualizację oprogramowania i przeglądy bezpieczeństwa.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Praktyki te wzorujemy na wymaganiach dyrektywy NIS2 oraz na zasadach zarządzania ryzykiem
|
||||
teleinformatycznym znanych z rozporządzenia DORA, choć - ze względu na charakter i skalę działalności -
|
||||
Platforma nie jest podmiotem objętym obowiązkami żadnego z tych aktów.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zmiany',
|
||||
title: '12. Zmiany Polityki prywatności',
|
||||
body: (
|
||||
<p>
|
||||
Politykę aktualizujemy, gdy zmienia się sposób działania Platformy. Aktualna wersja jest zawsze dostępna
|
||||
pod tym adresem wraz z datą obowiązywania. O istotnych zmianach informujemy na adres e-mail przypisany
|
||||
do konta.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<LegalPage
|
||||
<LegalDocument
|
||||
title="Polityka prywatności"
|
||||
subtitle="Informacje o przetwarzaniu danych osobowych oraz wykorzystaniu plików cookie na platformie Polska Lokalnie."
|
||||
>
|
||||
<h2>1. Administrator danych</h2>
|
||||
<p>Administratorem danych osobowych jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}. Kontakt w sprawach danych osobowych: {PROVIDER.email}.</p>
|
||||
|
||||
<h2>2. Jakie dane przetwarzamy</h2>
|
||||
<ul>
|
||||
<li>dane podane przy rejestracji i w profilu (imię i nazwisko lub nazwa, adres e-mail, numer telefonu, dane firmowe),</li>
|
||||
<li>dane zawarte w ogłoszeniach i wiadomościach,</li>
|
||||
<li>dane dotyczące płatności za usługi płatne (bez pełnych danych kart płatniczych),</li>
|
||||
<li>dane techniczne i o aktywności (adres IP, identyfikatory urządzenia, pliki cookie, logi).</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Cele i podstawy prawne</h2>
|
||||
<ul>
|
||||
<li>świadczenie usług i prowadzenie Konta - art. 6 ust. 1 lit. b RODO (wykonanie umowy),</li>
|
||||
<li>obsługa płatności i rozliczeń - art. 6 ust. 1 lit. b i c RODO,</li>
|
||||
<li>obowiązki prawne (m.in. rachunkowe, reklamacje) - art. 6 ust. 1 lit. c RODO,</li>
|
||||
<li>marketing własny, statystyki i bezpieczeństwo - art. 6 ust. 1 lit. f RODO (prawnie uzasadniony interes),</li>
|
||||
<li>działania marketingowe realizowane na podstawie zgody (np. newsletter) - art. 6 ust. 1 lit. a RODO.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Odbiorcy danych</h2>
|
||||
<p>Dane mogą być przekazywane podmiotom przetwarzającym je na zlecenie Administratora, w szczególności: dostawcy hostingu {PROVIDER.hosting}, Operatorowi płatności AutoPay (Autopay S.A.) w zakresie realizacji płatności, dostawcom usług e-mail i SMS oraz podmiotom świadczącym wsparcie techniczne - wyłącznie w zakresie niezbędnym i na podstawie odpowiednich umów powierzenia.</p>
|
||||
|
||||
<h2>5. Okres przechowywania</h2>
|
||||
<p>Dane przechowujemy przez czas trwania umowy (posiadania Konta), a po jej zakończeniu przez okres wymagany przepisami (m.in. rozliczeniowymi) lub do przedawnienia roszczeń. Dane przetwarzane na podstawie zgody - do jej wycofania.</p>
|
||||
|
||||
<h2>6. Prawa osób, których dane dotyczą</h2>
|
||||
<p>Przysługuje Państwu prawo dostępu do danych, ich sprostowania, usunięcia, ograniczenia przetwarzania, przenoszenia, wniesienia sprzeciwu oraz cofnięcia zgody w dowolnym momencie (bez wpływu na zgodność z prawem wcześniejszego przetwarzania). Mają Państwo także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (ul. Stawki 2, 00-193 Warszawa).</p>
|
||||
|
||||
<h2>7. Przekazywanie poza EOG</h2>
|
||||
<p>Dane są przetwarzane na terenie Europejskiego Obszaru Gospodarczego, a serwery Platformy znajdują się fizycznie na terytorium Rzeczypospolitej Polskiej. W razie przekazania danych poza EOG stosujemy odpowiednie zabezpieczenia (m.in. standardowe klauzule umowne).</p>
|
||||
|
||||
<h2>8. Pliki cookie</h2>
|
||||
<ul>
|
||||
<li><strong>Niezbędne</strong> - konieczne do działania Platformy (logowanie, sesja, bezpieczeństwo). Nie wymagają zgody.</li>
|
||||
<li><strong>Analityczne</strong> - pomagają zrozumieć sposób korzystania z Platformy i go ulepszać.</li>
|
||||
<li><strong>Marketingowe/funkcjonalne</strong> - umożliwiają dopasowanie treści i zapamiętanie preferencji.</li>
|
||||
</ul>
|
||||
<p>Zgodę na pliki inne niż niezbędne wyrażają Państwo w banerze cookie podczas pierwszej wizyty; można ją w każdej chwili zmienić w ustawieniach przeglądarki. Ograniczenie plików cookie może wpłynąć na dostępność części funkcji.</p>
|
||||
|
||||
<h2>9. Bezpieczeństwo</h2>
|
||||
<p>Stosujemy środki techniczne i organizacyjne odpowiadające ryzyku, w tym szyfrowanie transmisji (SSL/TLS) oraz kontrolę dostępu do danych.</p>
|
||||
|
||||
<h2>10. Zmiany polityki</h2>
|
||||
<p>Polityka może być aktualizowana. Aktualna wersja jest zawsze dostępna w Platformie wraz z datą ostatniej aktualizacji.</p>
|
||||
</LegalPage>
|
||||
subtitle="Jakie dane zbieramy, po co, komu je powierzamy i jak długo je przechowujemy."
|
||||
intro={(
|
||||
<p>
|
||||
Ten dokument opisuje rzeczywisty sposób działania serwisu. Nie używamy narzędzi analitycznych ani pikseli
|
||||
reklamowych, nie profilujemy Użytkowników i nie wysyłamy wiadomości marketingowych bez wyraźnej zgody.
|
||||
</p>
|
||||
)}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const COOKIE_CONSENT_KEY = 'polskalokalnie-cookie-consent';
|
||||
|
||||
/**
|
||||
* Czy uzytkownik zgodzil sie na tresci osadzane z serwisow zewnetrznych (wirtualne spacery
|
||||
* z YouTube, Matterport, Kuula). Takie osadzenie przekazuje dostawcy adres IP uzytkownika,
|
||||
* wiec bez zgody pokazujemy zastepnik z przyciskiem zamiast ladowac ramke.
|
||||
*
|
||||
* Mapy sa traktowane jako element niezbedny do ogladania ogloszen i nie zaleza od tej zgody.
|
||||
*/
|
||||
export function hasExternalEmbedConsent(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(COOKIE_CONSENT_KEY) === 'all';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const COOKIE_OPEN_EVENT = 'pl-cookie-open';
|
||||
|
||||
// Ponowne otwarcie ustawień plików cookie (np. z linku w stopce).
|
||||
// Ponowne otwarcie ustawień prywatności (np. z linku w stopce).
|
||||
export function openCookieSettings() {
|
||||
try {
|
||||
window.localStorage.removeItem(COOKIE_CONSENT_KEY);
|
||||
@@ -217,7 +678,7 @@ export function openCookieSettings() {
|
||||
window.dispatchEvent(new Event(COOKIE_OPEN_EVENT));
|
||||
}
|
||||
|
||||
// Baner zgody na pliki cookie - pojawia się przy pierwszej wizycie, dopóki Użytkownik nie dokona wyboru.
|
||||
// Baner zgody - pojawia się przy pierwszej wizycie, dopóki Użytkownik nie dokona wyboru.
|
||||
export function CookieBanner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
@@ -248,14 +709,15 @@ export function CookieBanner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Zgoda na pliki cookie">
|
||||
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Ustawienia prywatności">
|
||||
<div className="cookie-banner-inner">
|
||||
<div className="cookie-banner-text">
|
||||
<strong>Dbamy o Twoją prywatność</strong>
|
||||
<p>
|
||||
Używamy plików cookie, aby platforma działała poprawnie, oraz - za Twoją zgodą - do celów analitycznych
|
||||
i marketingowych. Szczegóły znajdziesz w <Link to={ROUTES.privacy}>Polityce prywatności</Link> i{' '}
|
||||
<Link to={ROUTES.terms}>Regulaminie</Link>.
|
||||
Nie używamy narzędzi analitycznych ani reklamowych. Zapisujemy tylko dane potrzebne do działania serwisu.
|
||||
Twoja zgoda dotyczy wyłącznie treści osadzanych z serwisów zewnętrznych - wirtualnych spacerów
|
||||
(YouTube, Matterport, Kuula), które otrzymują wtedy Twój adres IP. Szczegóły w{' '}
|
||||
<Link to={ROUTES.privacy}>Polityce prywatności</Link> i <Link to={ROUTES.terms}>Regulaminie</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="cookie-banner-actions">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user