Compare commits
31 Commits
bad91dc205
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 48dba240ee | |||
| d9819d0488 | |||
| cf02f480ed | |||
| 517e80d6bd | |||
| 09d4b2f32b | |||
| c9c78b6db5 | |||
| ca5f3ebfc1 | |||
| a4457df5f8 | |||
| 008223f311 | |||
| ad1d573e89 | |||
| a53cae7c37 | |||
| b850e4c092 | |||
| 0d149467e9 | |||
| a6c2378cbc | |||
| ac7a3a4efb | |||
| 3a555e3b24 | |||
| 1c1162c93e | |||
| c740612b06 | |||
| d4dd898387 | |||
| b5d121e24e | |||
| 036cb59331 | |||
| dbb6cf58d0 | |||
| 45a32d7ef0 | |||
| d6b6e3edd9 | |||
| 557c76257b | |||
| b9fecdea85 | |||
| e85cc70982 | |||
| 8cd2db38cc | |||
| e3cc999343 | |||
| 017ff4b2c6 | |||
| 556dd6cf6a |
@@ -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
|
||||
@@ -106,6 +106,28 @@ public class AccountEmailService {
|
||||
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Potwierdzony termin ogladania. Jedyny e-mail modulu spotkan - propozycje, odmowy
|
||||
* i odwolania zostaja w serwisie (watek rozmowy i dzwonek).
|
||||
*/
|
||||
public void sendMeetingConfirmed(String email, String name, String otherName, String listingTitle,
|
||||
String when, String location, Long conversationId) {
|
||||
String link = baseUrl + (conversationId == null ? "/konto/spotkania" : "/wiadomosci/" + conversationId);
|
||||
String where = location == null || location.isBlank() ? "" :
|
||||
"<p style=\"margin:0 0 4px\"><strong>Miejsce:</strong> " + HtmlUtils.htmlEscape(location) + "</p>";
|
||||
String about = listingTitle == null || listingTitle.isBlank() ? "" :
|
||||
"<p style=\"margin:0 0 4px\"><strong>Ogłoszenie:</strong> " + HtmlUtils.htmlEscape(listingTitle) + "</p>";
|
||||
String body = "<p>Termin oglądania został potwierdzony przez: <strong>"
|
||||
+ HtmlUtils.htmlEscape(otherName) + "</strong>.</p>"
|
||||
+ "<div style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;border-radius:6px;\">"
|
||||
+ "<p style=\"margin:0 0 4px\"><strong>Termin:</strong> " + HtmlUtils.htmlEscape(when) + "</p>"
|
||||
+ where + about + "</div>"
|
||||
+ "<p style=\"color:#6b7f96;font-size:13px\">Jeśli termin przestanie pasować, odwołaj go w serwisie -"
|
||||
+ " druga strona od razu to zobaczy.</p>";
|
||||
send(email, "Potwierdzone oglądanie: " + when,
|
||||
template("Termin potwierdzony", greeting(name), body, "Zobacz szczegóły", link), "SPOTKANIE", link);
|
||||
}
|
||||
|
||||
private void send(String email, String subject, String html, String label, String link) {
|
||||
SmtpConfig smtp = mailConfigService.getSmtp();
|
||||
boolean configured = smtp.isEnabled()
|
||||
@@ -121,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,5 +1,10 @@
|
||||
package pl.polskalokalnie.config;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import javax.sql.DataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -8,9 +13,32 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@Configuration
|
||||
public class SchemaFixer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SchemaFixer.class);
|
||||
|
||||
/**
|
||||
* Laty schematu pisane sa skladnia PostgreSQL (DO $$, to_regclass, lo_get, split_part...),
|
||||
* bo taka baza dziala na serwerze. W testach schemat tworzy Hibernate z encji na H2,
|
||||
* wiec nie ma czego latac - bez tego warunku kazdy @SpringBootTest wywracal sie przy starcie.
|
||||
*/
|
||||
private static boolean isPostgres(JdbcTemplate jdbcTemplate) {
|
||||
DataSource dataSource = jdbcTemplate.getDataSource();
|
||||
if (dataSource == null) {
|
||||
return false;
|
||||
}
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
return "PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName());
|
||||
} catch (SQLException ex) {
|
||||
log.warn("Nie udalo sie rozpoznac bazy danych, pomijam poprawki schematu: {}", ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
|
||||
return args -> {
|
||||
if (!isPostgres(jdbcTemplate)) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI),
|
||||
* a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte.
|
||||
* Ulubione ogloszenia uzytkownika - trzymane na koncie, wiec sa te same na kazdym urzadzeniu.
|
||||
* Z tych samych danych liczymy wlascicielowi, ile osob zapisalo jego oferte.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/listings")
|
||||
@@ -49,30 +49,38 @@ public class ListingFavoriteController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie
|
||||
* i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego,
|
||||
* z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie.
|
||||
*/
|
||||
@PostMapping("/favorites/sync")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> sync(@RequestBody SyncRequest request, Authentication authentication) {
|
||||
if (request == null || request.listingIds() == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
/** Ulubione zalogowanego uzytkownika - te same na kazdym urzadzeniu. */
|
||||
@GetMapping("/favorites")
|
||||
public List<ListingResponse> myFavorites(Authentication authentication) {
|
||||
List<Long> listingIds = favoriteRepository.findByUserEmailIgnoreCase(authentication.getName()).stream()
|
||||
.map(ListingFavorite::getListingId)
|
||||
.toList();
|
||||
if (listingIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
|
||||
return listingRepository.findAllById(listingIds).stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.map(ListingResponse::publicFrom)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dopisanie ulubionych zapisanych wczesniej w przegladarce. Tylko dodaje - nigdy nie kasuje,
|
||||
* zeby wejscie z pustej przegladarki nie wyczyscilo listy zebranej na innym urzadzeniu.
|
||||
*/
|
||||
@PostMapping("/favorites/import")
|
||||
@Transactional
|
||||
public List<ListingResponse> importLocal(@RequestBody SyncRequest request, Authentication authentication) {
|
||||
if (request != null && request.listingIds() != null) {
|
||||
String userEmail = authentication.getName();
|
||||
List<Long> wanted = request.listingIds().stream()
|
||||
request.listingIds().stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.filter(listingRepository::existsById)
|
||||
.toList();
|
||||
|
||||
favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream()
|
||||
.filter(favorite -> !wanted.contains(favorite.getListingId()))
|
||||
.forEach(favoriteRepository::delete);
|
||||
|
||||
wanted.forEach(listingId -> save(listingId, userEmail));
|
||||
return ResponseEntity.noContent().build();
|
||||
.forEach(listingId -> save(listingId, userEmail));
|
||||
}
|
||||
return myFavorites(authentication);
|
||||
}
|
||||
|
||||
/** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package pl.polskalokalnie.meeting;
|
||||
|
||||
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 java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
/**
|
||||
* Spotkanie (ogladanie nieruchomosci) albo wlasny wpis w kalendarzu konta. Wczesniej kalendarz
|
||||
* zyl w localStorage jednej przegladarki - druga strona nic o nim nie wiedziala.
|
||||
*
|
||||
* <p>Wpis prywatny ma puste conversationId i counterpartId oraz status CONFIRMED - nikt poza
|
||||
* autorem go nie widzi. Spotkanie umowione z druga strona powstaje z rozmowy o ogloszeniu
|
||||
* i jest widoczne dla obu uczestnikow.</p>
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "meetings")
|
||||
public class Meeting {
|
||||
|
||||
public enum Status {
|
||||
/** Termin zaproponowany, czeka na odpowiedz drugiej strony. */
|
||||
PROPOSED,
|
||||
/** Termin potwierdzony przez druga strone. */
|
||||
CONFIRMED,
|
||||
/** Druga strona odrzucila termin. */
|
||||
DECLINED,
|
||||
/** Odwolane - moze to zrobic kazda ze stron, do konca spotkania. */
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "conversation_id")
|
||||
private Long conversationId;
|
||||
|
||||
@Column(name = "listing_id")
|
||||
private Long listingId;
|
||||
|
||||
@Column(name = "listing_title", length = 200)
|
||||
private String listingTitle;
|
||||
|
||||
/** Kto zaproponowal termin (albo autor wpisu prywatnego). */
|
||||
@Column(name = "proposer_id", nullable = false)
|
||||
private Long proposerId;
|
||||
|
||||
/** Druga strona spotkania; null dla wpisu prywatnego. */
|
||||
@Column(name = "counterpart_id")
|
||||
private Long counterpartId;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String title;
|
||||
|
||||
@Column(length = 200)
|
||||
private String note;
|
||||
|
||||
@Column(length = 200)
|
||||
private String location;
|
||||
|
||||
@Column(name = "meeting_date", nullable = false)
|
||||
private LocalDate meetingDate;
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
private LocalTime startTime;
|
||||
|
||||
@Column(name = "end_time", nullable = false)
|
||||
private LocalTime endTime;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private Status status = Status.PROPOSED;
|
||||
|
||||
/** Kto odwolal spotkanie - druga strona widzi, po czyjej stronie byla zmiana. */
|
||||
@Column(name = "cancelled_by_id")
|
||||
private Long cancelledById;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt = Instant.now();
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt = Instant.now();
|
||||
|
||||
public boolean isPrivateNote() {
|
||||
return counterpartId == null;
|
||||
}
|
||||
|
||||
public boolean isParticipant(Long userId) {
|
||||
return proposerId.equals(userId) || (counterpartId != null && counterpartId.equals(userId));
|
||||
}
|
||||
|
||||
public Long otherParticipant(Long userId) {
|
||||
return proposerId.equals(userId) ? counterpartId : proposerId;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getConversationId() {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
public void setConversationId(Long conversationId) {
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public String getListingTitle() {
|
||||
return listingTitle;
|
||||
}
|
||||
|
||||
public void setListingTitle(String listingTitle) {
|
||||
this.listingTitle = listingTitle;
|
||||
}
|
||||
|
||||
public Long getProposerId() {
|
||||
return proposerId;
|
||||
}
|
||||
|
||||
public void setProposerId(Long proposerId) {
|
||||
this.proposerId = proposerId;
|
||||
}
|
||||
|
||||
public Long getCounterpartId() {
|
||||
return counterpartId;
|
||||
}
|
||||
|
||||
public void setCounterpartId(Long counterpartId) {
|
||||
this.counterpartId = counterpartId;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getNote() {
|
||||
return note;
|
||||
}
|
||||
|
||||
public void setNote(String note) {
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public LocalDate getMeetingDate() {
|
||||
return meetingDate;
|
||||
}
|
||||
|
||||
public void setMeetingDate(LocalDate meetingDate) {
|
||||
this.meetingDate = meetingDate;
|
||||
}
|
||||
|
||||
public LocalTime getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(LocalTime startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public LocalTime getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(LocalTime endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public Status getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Status status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getCancelledById() {
|
||||
return cancelledById;
|
||||
}
|
||||
|
||||
public void setCancelledById(Long cancelledById) {
|
||||
this.cancelledById = cancelledById;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package pl.polskalokalnie.meeting;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
|
||||
import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
/** Kalendarz spotkan zalogowanego uzytkownika - wlasne wpisy i ogladania umowione z druga strona. */
|
||||
@RestController
|
||||
@RequestMapping("/api/meetings")
|
||||
public class MeetingController {
|
||||
|
||||
private final MeetingService meetingService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public MeetingController(MeetingService meetingService, UserRepository userRepository) {
|
||||
this.meetingService = meetingService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<MeetingResponse> list(Authentication authentication) {
|
||||
return meetingService.list(me(authentication));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public MeetingResponse create(@RequestBody MeetingRequest request, Authentication authentication) {
|
||||
return meetingService.create(me(authentication), request);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public MeetingResponse update(@PathVariable Long id, @RequestBody MeetingRequest request, Authentication authentication) {
|
||||
return meetingService.updatePrivate(me(authentication), id, request);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/confirm")
|
||||
public MeetingResponse confirm(@PathVariable Long id, Authentication authentication) {
|
||||
return meetingService.respond(me(authentication), id, true);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/decline")
|
||||
public MeetingResponse decline(@PathVariable Long id, Authentication authentication) {
|
||||
return meetingService.respond(me(authentication), id, false);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> cancel(@PathVariable Long id, Authentication authentication) {
|
||||
meetingService.cancel(me(authentication), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private AppUser me(Authentication authentication) {
|
||||
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Zaloguj się ponownie"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.meeting;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
|
||||
public final class MeetingDtos {
|
||||
|
||||
private MeetingDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Propozycja terminu albo wlasny wpis w kalendarzu. Z conversationId powstaje spotkanie
|
||||
* z druga strona rozmowy; bez niego - wpis prywatny, widoczny tylko dla autora.
|
||||
*/
|
||||
public record MeetingRequest(Long conversationId, String title, String note, String location,
|
||||
LocalDate date, LocalTime start, LocalTime end) {
|
||||
}
|
||||
|
||||
/**
|
||||
* status: PROPOSED / CONFIRMED / DECLINED / CANCELLED.
|
||||
* proposedByMe - czy to ja zaproponowalem termin (tylko druga strona moze go potwierdzic).
|
||||
* privateNote - wpis wlasny, bez drugiej strony.
|
||||
*/
|
||||
public record MeetingResponse(Long id, Long conversationId, Long listingId, String listingTitle,
|
||||
String title, String note, String location, LocalDate date, LocalTime start,
|
||||
LocalTime end, String status, boolean proposedByMe, boolean privateNote,
|
||||
String otherName, boolean cancelledByMe) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pl.polskalokalnie.meeting;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface MeetingRepository extends JpaRepository<Meeting, Long> {
|
||||
|
||||
@Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId"
|
||||
+ " order by m.meetingDate asc, m.startTime asc")
|
||||
List<Meeting> findForUser(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package pl.polskalokalnie.meeting;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
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.auth.AccountEmailService;
|
||||
import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
|
||||
import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
|
||||
import pl.polskalokalnie.message.Conversation;
|
||||
import pl.polskalokalnie.message.ConversationRepository;
|
||||
import pl.polskalokalnie.message.ConversationService;
|
||||
import pl.polskalokalnie.notification.NotificationService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
import pl.polskalokalnie.user.UserSettings;
|
||||
import pl.polskalokalnie.user.UserSettingsRepository;
|
||||
|
||||
/**
|
||||
* Umawianie ogladania nieruchomosci. Termin proponuje jedna ze stron rozmowy o ogloszeniu,
|
||||
* druga potwierdza albo odrzuca; odwolac moze kazda ze stron, do konca spotkania.
|
||||
*
|
||||
* <p>Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail
|
||||
* wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.</p>
|
||||
*/
|
||||
@Service
|
||||
public class MeetingService {
|
||||
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
private final MeetingRepository meetingRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final ConversationService conversationService;
|
||||
private final UserRepository userRepository;
|
||||
private final UserSettingsRepository userSettingsRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final AccountEmailService accountEmailService;
|
||||
|
||||
public MeetingService(MeetingRepository meetingRepository,
|
||||
ConversationRepository conversationRepository,
|
||||
ConversationService conversationService,
|
||||
UserRepository userRepository,
|
||||
UserSettingsRepository userSettingsRepository,
|
||||
NotificationService notificationService,
|
||||
AccountEmailService accountEmailService) {
|
||||
this.meetingRepository = meetingRepository;
|
||||
this.conversationRepository = conversationRepository;
|
||||
this.conversationService = conversationService;
|
||||
this.userRepository = userRepository;
|
||||
this.userSettingsRepository = userSettingsRepository;
|
||||
this.notificationService = notificationService;
|
||||
this.accountEmailService = accountEmailService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MeetingResponse> list(AppUser me) {
|
||||
List<Meeting> meetings = meetingRepository.findForUser(me.getId());
|
||||
Map<Long, AppUser> others = userRepository.findAllById(meetings.stream()
|
||||
.map(meeting -> meeting.otherParticipant(me.getId()))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
|
||||
return meetings.stream().map(meeting -> toResponse(meeting, me, others.get(meeting.otherParticipant(me.getId())))).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MeetingResponse create(AppUser me, MeetingRequest request) {
|
||||
validate(request);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setProposerId(me.getId());
|
||||
meeting.setTitle(request.title().trim());
|
||||
meeting.setNote(trimOrNull(request.note()));
|
||||
meeting.setLocation(trimOrNull(request.location()));
|
||||
meeting.setMeetingDate(request.date());
|
||||
meeting.setStartTime(request.start());
|
||||
meeting.setEndTime(request.end());
|
||||
|
||||
if (request.conversationId() == null) {
|
||||
// Wpis wlasny - nie ma z kim uzgadniac terminu, wiec od razu jest potwierdzony.
|
||||
meeting.setStatus(Meeting.Status.CONFIRMED);
|
||||
return toResponse(meetingRepository.save(meeting), me, null);
|
||||
}
|
||||
|
||||
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());
|
||||
meeting.setListingTitle(conversation.getListingTitle());
|
||||
meeting.setCounterpartId(other.getId());
|
||||
meeting.setStatus(Meeting.Status.PROPOSED);
|
||||
Meeting saved = meetingRepository.save(meeting);
|
||||
|
||||
String when = when(saved);
|
||||
conversationService.appendMeetingNote(conversation, me,
|
||||
"Propozycja oglądania: " + when + (saved.getLocation() == null ? "" : ", " + saved.getLocation()));
|
||||
notify(other, "Propozycja oglądania", displayName(me) + " proponuje termin: " + when, conversation.getId());
|
||||
return toResponse(saved, me, other);
|
||||
}
|
||||
|
||||
/** Zmiana wlasnego wpisu w kalendarzu. Terminu umowionego z druga strona sie nie edytuje - odwoluje sie go i proponuje nowy. */
|
||||
@Transactional
|
||||
public MeetingResponse updatePrivate(AppUser me, Long meetingId, MeetingRequest request) {
|
||||
validate(request);
|
||||
Meeting meeting = requireParticipant(me, meetingId);
|
||||
if (!meeting.isPrivateNote()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Umówionego terminu nie da się zmienić - odwołaj go i zaproponuj nowy");
|
||||
}
|
||||
meeting.setTitle(request.title().trim());
|
||||
meeting.setNote(trimOrNull(request.note()));
|
||||
meeting.setLocation(trimOrNull(request.location()));
|
||||
meeting.setMeetingDate(request.date());
|
||||
meeting.setStartTime(request.start());
|
||||
meeting.setEndTime(request.end());
|
||||
meeting.setUpdatedAt(Instant.now());
|
||||
return toResponse(meeting, me, null);
|
||||
}
|
||||
|
||||
/** Potwierdzenie albo odrzucenie terminu - tylko przez osobe, ktora go dostala. */
|
||||
@Transactional
|
||||
public MeetingResponse respond(AppUser me, Long meetingId, boolean accept) {
|
||||
Meeting meeting = requireParticipant(me, meetingId);
|
||||
if (meeting.isPrivateNote() || meeting.getProposerId().equals(me.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Termin potwierdza osoba, która go otrzymała");
|
||||
}
|
||||
if (meeting.getStatus() != Meeting.Status.PROPOSED) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten termin został już rozpatrzony");
|
||||
}
|
||||
meeting.setStatus(accept ? Meeting.Status.CONFIRMED : Meeting.Status.DECLINED);
|
||||
meeting.setUpdatedAt(Instant.now());
|
||||
|
||||
AppUser other = requireUser(meeting.getProposerId());
|
||||
String when = when(meeting);
|
||||
Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
|
||||
if (conversation != null) {
|
||||
conversationService.appendMeetingNote(conversation, me,
|
||||
(accept ? "Potwierdzam oglądanie: " : "Nie pasuje mi termin: ") + when);
|
||||
}
|
||||
notify(other, accept ? "Termin potwierdzony" : "Termin odrzucony",
|
||||
displayName(me) + (accept ? " potwierdza oglądanie: " : " nie może w terminie: ") + when,
|
||||
meeting.getConversationId());
|
||||
if (accept) {
|
||||
sendConfirmationEmail(other, me, meeting);
|
||||
sendConfirmationEmail(me, other, meeting);
|
||||
}
|
||||
return toResponse(meeting, me, other);
|
||||
}
|
||||
|
||||
/** Odwolac moze kazda ze stron, do konca spotkania. Wpis wlasny po prostu znika. */
|
||||
@Transactional
|
||||
public void cancel(AppUser me, Long meetingId) {
|
||||
Meeting meeting = requireParticipant(me, meetingId);
|
||||
if (meeting.isPrivateNote()) {
|
||||
meetingRepository.delete(meeting);
|
||||
return;
|
||||
}
|
||||
if (meeting.getStatus() == Meeting.Status.CANCELLED) {
|
||||
return;
|
||||
}
|
||||
meeting.setStatus(Meeting.Status.CANCELLED);
|
||||
meeting.setCancelledById(me.getId());
|
||||
meeting.setUpdatedAt(Instant.now());
|
||||
|
||||
AppUser other = requireUser(meeting.otherParticipant(me.getId()));
|
||||
String when = when(meeting);
|
||||
Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
|
||||
if (conversation != null) {
|
||||
conversationService.appendMeetingNote(conversation, me, "Odwołanie oglądania: " + when);
|
||||
}
|
||||
notify(other, "Oglądanie odwołane", displayName(me) + " odwołuje termin: " + when, meeting.getConversationId());
|
||||
}
|
||||
|
||||
private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) {
|
||||
if (!wantsNotifications(recipient)) {
|
||||
return;
|
||||
}
|
||||
accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other),
|
||||
meeting.getListingTitle(), when(meeting), meeting.getLocation(), meeting.getConversationId());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void validate(MeetingRequest request) {
|
||||
if (request == null || request.title() == null || request.title().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj nazwę spotkania");
|
||||
}
|
||||
if (request.date() == null || request.start() == null || request.end() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj datę i godziny spotkania");
|
||||
}
|
||||
if (!request.end().isAfter(request.start())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Godzina zakończenia musi być późniejsza niż rozpoczęcia");
|
||||
}
|
||||
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) {
|
||||
Conversation conversation = conversationRepository.findById(conversationId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
|
||||
if (!conversation.isParticipant(me.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
|
||||
}
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private Meeting requireParticipant(AppUser me, Long meetingId) {
|
||||
Meeting meeting = meetingRepository.findById(meetingId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono spotkania"));
|
||||
if (!meeting.isParticipant(me.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tego spotkania");
|
||||
}
|
||||
return meeting;
|
||||
}
|
||||
|
||||
private AppUser requireUser(Long userId) {
|
||||
return userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto drugiej strony zostało usunięte"));
|
||||
}
|
||||
|
||||
private MeetingResponse toResponse(Meeting meeting, AppUser me, AppUser other) {
|
||||
return new MeetingResponse(
|
||||
meeting.getId(),
|
||||
meeting.getConversationId(),
|
||||
meeting.getListingId(),
|
||||
meeting.getListingTitle(),
|
||||
meeting.getTitle(),
|
||||
meeting.getNote(),
|
||||
meeting.getLocation(),
|
||||
meeting.getMeetingDate(),
|
||||
meeting.getStartTime(),
|
||||
meeting.getEndTime(),
|
||||
meeting.getStatus().name(),
|
||||
meeting.getProposerId().equals(me.getId()),
|
||||
meeting.isPrivateNote(),
|
||||
meeting.isPrivateNote() ? null : displayName(other),
|
||||
meeting.getCancelledById() != null && meeting.getCancelledById().equals(me.getId()));
|
||||
}
|
||||
|
||||
private static String displayName(AppUser user) {
|
||||
if (user == null) {
|
||||
return "Konto usunięte";
|
||||
}
|
||||
String name = user.getFullName();
|
||||
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) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -167,6 +173,26 @@ public class ConversationService {
|
||||
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wpis o spotkaniu w watku rozmowy. Swiadomie NIE publikuje zdarzenia o nowej wiadomosci:
|
||||
* o terminach powiadamia modul spotkan (dzwonek zawsze, e-mail tylko przy potwierdzeniu),
|
||||
* wiec kazda propozycja nie ma prawa wygenerowac osobnego maila.
|
||||
*/
|
||||
@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());
|
||||
message.setRecipientId(conversation.otherParticipant(sender.getId()));
|
||||
message.setContent(content.trim());
|
||||
messageRepository.save(message);
|
||||
|
||||
conversation.setLastMessageAt(Instant.now());
|
||||
conversation.restoreForBoth();
|
||||
}
|
||||
|
||||
private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) {
|
||||
textModerationService.validateOrThrow(content);
|
||||
String trimmed = content.trim();
|
||||
@@ -246,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,193 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwanie uzytkownika ("Zapisz wyszukiwanie" na liscie ofert). Wczesniej
|
||||
* zylo tylko w localStorage przegladarki, wiec ginelo po zmianie urzadzenia albo wyczyszczeniu
|
||||
* danych. Nie wysylamy z niego zadnych powiadomien - to lista do wygodnego wracania do filtrow.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "saved_searches")
|
||||
public class SavedSearch {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_email", nullable = false, length = 180)
|
||||
private String userEmail;
|
||||
|
||||
/** "buy" albo "rent" - te same wartosci co w adresach /kupno i /wynajem. */
|
||||
@Column(nullable = false, length = 10)
|
||||
private String mode;
|
||||
|
||||
@Column(length = 120)
|
||||
private String city;
|
||||
|
||||
@Column(name = "property_type", length = 60)
|
||||
private String propertyType;
|
||||
|
||||
@Column(name = "price_min")
|
||||
private Long priceMin;
|
||||
|
||||
@Column(name = "price_max")
|
||||
private Long priceMax;
|
||||
|
||||
@Column(name = "area_min")
|
||||
private Integer areaMin;
|
||||
|
||||
@Column(name = "area_max")
|
||||
private Integer areaMax;
|
||||
|
||||
private Integer rooms;
|
||||
|
||||
@Column(name = "secondary_market", nullable = false)
|
||||
private boolean secondaryMarket;
|
||||
|
||||
@Column(name = "primary_market", nullable = false)
|
||||
private boolean primaryMarket;
|
||||
|
||||
@Column(name = "no_fee_only", nullable = false)
|
||||
private boolean noFeeOnly;
|
||||
|
||||
/** Pozostale filtry ("wiecej filtrow") jako JSON - jest ich wiele i zmieniaja sie z widokiem. */
|
||||
@Column(name = "more_filters", columnDefinition = "text")
|
||||
private String moreFilters;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
public void setUserEmail(String userEmail) {
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(String propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public Long getPriceMin() {
|
||||
return priceMin;
|
||||
}
|
||||
|
||||
public void setPriceMin(Long priceMin) {
|
||||
this.priceMin = priceMin;
|
||||
}
|
||||
|
||||
public Long getPriceMax() {
|
||||
return priceMax;
|
||||
}
|
||||
|
||||
public void setPriceMax(Long priceMax) {
|
||||
this.priceMax = priceMax;
|
||||
}
|
||||
|
||||
public Integer getAreaMin() {
|
||||
return areaMin;
|
||||
}
|
||||
|
||||
public void setAreaMin(Integer areaMin) {
|
||||
this.areaMin = areaMin;
|
||||
}
|
||||
|
||||
public Integer getAreaMax() {
|
||||
return areaMax;
|
||||
}
|
||||
|
||||
public void setAreaMax(Integer areaMax) {
|
||||
this.areaMax = areaMax;
|
||||
}
|
||||
|
||||
public Integer getRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
public void setRooms(Integer rooms) {
|
||||
this.rooms = rooms;
|
||||
}
|
||||
|
||||
public boolean isSecondaryMarket() {
|
||||
return secondaryMarket;
|
||||
}
|
||||
|
||||
public void setSecondaryMarket(boolean secondaryMarket) {
|
||||
this.secondaryMarket = secondaryMarket;
|
||||
}
|
||||
|
||||
public boolean isPrimaryMarket() {
|
||||
return primaryMarket;
|
||||
}
|
||||
|
||||
public void setPrimaryMarket(boolean primaryMarket) {
|
||||
this.primaryMarket = primaryMarket;
|
||||
}
|
||||
|
||||
public boolean isNoFeeOnly() {
|
||||
return noFeeOnly;
|
||||
}
|
||||
|
||||
public void setNoFeeOnly(boolean noFeeOnly) {
|
||||
this.noFeeOnly = noFeeOnly;
|
||||
}
|
||||
|
||||
public String getMoreFilters() {
|
||||
return moreFilters;
|
||||
}
|
||||
|
||||
public void setMoreFilters(String moreFilters) {
|
||||
this.moreFilters = moreFilters;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Zapisane wyszukiwania zalogowanego uzytkownika. Dostep wymaga logowania (regula
|
||||
* anyRequest().authenticated() w SecurityConfig), a kazda operacja dotyczy wylacznie
|
||||
* wpisow wlasciciela konta. Zadnych powiadomien - to tylko lista zapisanych filtrow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/saved-searches")
|
||||
public class SavedSearchController {
|
||||
|
||||
/** Tyle samo, ile dotad miescil zapis w przegladarce - chroni przed zasypaniem listy. */
|
||||
static final int LIMIT = 60;
|
||||
|
||||
private final SavedSearchRepository repository;
|
||||
|
||||
public SavedSearchController(SavedSearchRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** Kryteria wyszukiwania - dokladnie to, co pokazuje pasek filtrow na liscie ofert. */
|
||||
public record SavedSearchRequest(String mode, String city, String propertyType, Long priceMin, Long priceMax,
|
||||
Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny, boolean pierwotny,
|
||||
boolean noFeeOnly, String more) {
|
||||
}
|
||||
|
||||
public record SavedSearchResponse(Long id, String mode, String city, String propertyType, Long priceMin,
|
||||
Long priceMax, Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny,
|
||||
boolean pierwotny, boolean noFeeOnly, String more, Instant createdAt,
|
||||
Instant updatedAt) {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<SavedSearchResponse> list(Authentication authentication) {
|
||||
return repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(authentication.getName()).stream()
|
||||
.map(SavedSearchController::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public SavedSearchResponse add(@RequestBody SavedSearchRequest request, Authentication authentication) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak trybu wyszukiwania");
|
||||
}
|
||||
String userEmail = authentication.getName();
|
||||
List<SavedSearch> existing = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
SavedSearch duplicate = existing.stream().filter(saved -> sameCriteria(saved, request)).findFirst().orElse(null);
|
||||
if (duplicate != null) {
|
||||
return toResponse(duplicate);
|
||||
}
|
||||
// Najstarsze wpisy ponad limit usuwamy, zeby zapis zawsze sie udal.
|
||||
existing.stream().skip(LIMIT - 1L).forEach(repository::delete);
|
||||
return toResponse(repository.save(fromRequest(request, userEmail)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Przeniesienie wyszukiwan zapisanych wczesniej w przegladarce. Dodajemy tylko te,
|
||||
* ktorych uzytkownik nie ma jeszcze na koncie - import mozna powtorzyc bez duplikatow.
|
||||
*/
|
||||
@PostMapping("/import")
|
||||
@Transactional
|
||||
public List<SavedSearchResponse> importLocal(@RequestBody List<SavedSearchRequest> requests, Authentication authentication) {
|
||||
String userEmail = authentication.getName();
|
||||
if (requests != null) {
|
||||
for (SavedSearchRequest request : requests) {
|
||||
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
List<SavedSearch> current = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||
if (current.size() >= LIMIT || current.stream().anyMatch(saved -> sameCriteria(saved, request))) {
|
||||
continue;
|
||||
}
|
||||
repository.save(fromRequest(request, userEmail));
|
||||
}
|
||||
}
|
||||
return list(authentication);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> remove(@PathVariable Long id, Authentication authentication) {
|
||||
repository.findById(id)
|
||||
.filter(saved -> saved.getUserEmail().equalsIgnoreCase(authentication.getName()))
|
||||
.ifPresent(repository::delete);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static SavedSearch fromRequest(SavedSearchRequest request, String userEmail) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
Instant now = Instant.now();
|
||||
saved.setUserEmail(userEmail);
|
||||
saved.setMode(request.mode());
|
||||
saved.setCity(request.city());
|
||||
saved.setPropertyType(request.propertyType());
|
||||
saved.setPriceMin(request.priceMin());
|
||||
saved.setPriceMax(request.priceMax());
|
||||
saved.setAreaMin(request.areaMin());
|
||||
saved.setAreaMax(request.areaMax());
|
||||
saved.setRooms(request.rooms());
|
||||
saved.setSecondaryMarket(request.wtorny());
|
||||
saved.setPrimaryMarket(request.pierwotny());
|
||||
saved.setNoFeeOnly(request.noFeeOnly());
|
||||
saved.setMoreFilters(request.more());
|
||||
saved.setCreatedAt(now);
|
||||
saved.setUpdatedAt(now);
|
||||
return saved;
|
||||
}
|
||||
|
||||
static boolean sameCriteria(SavedSearch saved, SavedSearchRequest request) {
|
||||
return Objects.equals(saved.getMode(), request.mode())
|
||||
&& Objects.equals(trim(saved.getCity()), trim(request.city()))
|
||||
&& Objects.equals(trim(saved.getPropertyType()), trim(request.propertyType()))
|
||||
&& Objects.equals(saved.getPriceMin(), request.priceMin())
|
||||
&& Objects.equals(saved.getPriceMax(), request.priceMax())
|
||||
&& Objects.equals(saved.getAreaMin(), request.areaMin())
|
||||
&& Objects.equals(saved.getAreaMax(), request.areaMax())
|
||||
&& Objects.equals(saved.getRooms(), request.rooms())
|
||||
&& saved.isSecondaryMarket() == request.wtorny()
|
||||
&& saved.isPrimaryMarket() == request.pierwotny()
|
||||
&& saved.isNoFeeOnly() == request.noFeeOnly()
|
||||
&& Objects.equals(trim(saved.getMoreFilters()), trim(request.more()));
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static SavedSearchResponse toResponse(SavedSearch saved) {
|
||||
return new SavedSearchResponse(saved.getId(), saved.getMode(), saved.getCity(), saved.getPropertyType(),
|
||||
saved.getPriceMin(), saved.getPriceMax(), saved.getAreaMin(), saved.getAreaMax(), saved.getRooms(),
|
||||
saved.isSecondaryMarket(), saved.isPrimaryMarket(), saved.isNoFeeOnly(), saved.getMoreFilters(),
|
||||
saved.getCreatedAt(), saved.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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(',', ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {
|
||||
|
||||
List<SavedSearch> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||
|
||||
void deleteByUserEmailIgnoreCase(String userEmail);
|
||||
}
|
||||
@@ -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,38 @@
|
||||
package pl.polskalokalnie.search;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import pl.polskalokalnie.search.SavedSearchController.SavedSearchRequest;
|
||||
|
||||
class SavedSearchControllerTest {
|
||||
|
||||
private SavedSearch saved(String city, Long priceMax, String more) {
|
||||
SavedSearch saved = new SavedSearch();
|
||||
saved.setMode("buy");
|
||||
saved.setCity(city);
|
||||
saved.setPropertyType("Mieszkanie");
|
||||
saved.setPriceMax(priceMax);
|
||||
saved.setSecondaryMarket(true);
|
||||
saved.setMoreFilters(more);
|
||||
return saved;
|
||||
}
|
||||
|
||||
private SavedSearchRequest request(String city, Long priceMax, String more) {
|
||||
return new SavedSearchRequest("buy", city, "Mieszkanie", null, priceMax, null, null, null, true, false, false, more);
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsSameCriteriaAsDuplicate() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{}"))).isTrue();
|
||||
// Spacje i brak wartosci nie robia z wyszukiwania nowego wpisu.
|
||||
assertThat(SavedSearchController.sameCriteria(saved(" Kraków ", 800000L, null), request("Kraków", 800000L, ""))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentFiltersAreSeparateSearches() {
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 900000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Gdańsk", 800000L, "{}"))).isFalse();
|
||||
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{\"balkon\":\"tak\"}"))).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user