SMPT, SMS, Rejestracja + GUS

This commit is contained in:
2026-08-09 21:12:16 +02:00
parent a0c9d7947b
commit c464c5f708
78 changed files with 5841 additions and 258 deletions
@@ -0,0 +1,113 @@
package pl.polskalokalnie.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
/**
* Wysylka transakcyjnych wiadomosci e-mail (aktywacja konta, reset hasla) w firmowym szablonie HTML
* (logo + nazwa u gory, tresc, przycisk CTA). Korzysta z konfiguracji SMTP z panelu admina.
*
* Gdy SMTP nie jest skonfigurowany/wlaczony - link jest logowany (tryb deweloperski), aby proces
* dalo sie przetestowac bez realnej skrzynki. Po skonfigurowaniu SMTP wiadomosci ida realnie.
*/
@Service
public class AccountEmailService {
private static final Logger log = LoggerFactory.getLogger(AccountEmailService.class);
private final EmailSender emailSender;
private final MailConfigService mailConfigService;
private final String baseUrl;
public AccountEmailService(EmailSender emailSender,
MailConfigService mailConfigService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.emailSender = emailSender;
this.mailConfigService = mailConfigService;
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
}
public void sendActivation(String email, String name, String token) {
String link = baseUrl + "/aktywacja/" + token;
String html = template(
"Aktywuj swoje konto",
greeting(name),
"<p>Dziękujemy za rejestrację w serwisie Polska Lokalnie. Aby dokończyć zakładanie konta i móc"
+ " korzystać ze wszystkich funkcji, potwierdź swój adres e-mail klikając poniższy przycisk.</p>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Link jest ważny przez 48 godzin. Jeśli to nie Ty"
+ " zakładałeś konto, zignoruj tę wiadomość.</p>",
"Aktywuj konto",
link);
send(email, "Aktywuj konto w serwisie Polska Lokalnie", html, "AKTYWACJA", link);
}
public void sendPasswordReset(String email, String name, String token) {
String link = baseUrl + "/reset-hasla/" + token;
String html = template(
"Ustaw nowe hasło",
greeting(name),
"<p>Otrzymaliśmy prośbę o zresetowanie hasła do Twojego konta. Kliknij poniższy przycisk, aby ustawić"
+ " nowe hasło.</p>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Link jest ważny przez 1 godzinę. Dotychczasowe hasło"
+ " pozostaje aktywne do momentu ustawienia nowego. Jeśli nie prosiłeś o zmianę hasła, zignoruj tę wiadomość.</p>",
"Ustaw nowe hasło",
link);
send(email, "Reset hasła w serwisie Polska Lokalnie", html, "RESET HASŁA", link);
}
private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled()
&& smtp.getHost() != null && !smtp.getHost().isBlank();
if (!configured) {
// Tryb deweloperski - brak skonfigurowanej skrzynki. Logujemy link, by dalo sie przetestowac.
log.info("[E-MAIL:{}] SMTP nieskonfigurowany. Link dla {}: {}", label, email, link);
return;
}
SendResult result = emailSender.send(smtp, email, subject, html);
if (!result.ok()) {
log.warn("[E-MAIL:{}] Nie udalo sie wyslac do {} ({}). Link: {}", label, email, result.error(), link);
}
}
private static String greeting(String name) {
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
return "<p>Cześć" + who + ",</p>";
}
/** Firmowy szablon HTML: naglowek z logo i nazwa, tresc, przycisk CTA, stopka. */
private String template(String heading, String greetingHtml, String bodyHtml, String ctaLabel, String ctaUrl) {
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;\">"
// Naglowek z logo + nazwa
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\""
+ " style=\"vertical-align:middle;border-radius:8px;\">"
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
+ "</div>"
// Tresc
+ "<div style=\"background:#ffffff;padding:28px 24px;\">"
+ "<h1 style=\"margin:0 0 16px;font-size:22px;color:#13243d;\">" + heading + "</h1>"
+ "<div style=\"font-size:15px;line-height:1.6;\">" + greetingHtml + bodyHtml + "</div>"
+ "<div style=\"text-align:center;margin:26px 0 8px;\">"
+ "<a href=\"" + ctaUrl + "\" style=\"display:inline-block;background:#12a764;color:#ffffff;"
+ "text-decoration:none;font-weight:bold;font-size:15px;padding:13px 26px;border-radius:10px;\">"
+ ctaLabel + "</a></div>"
+ "<p style=\"font-size:12px;color:#8a99ad;word-break:break-all;\">Jeśli przycisk nie działa, skopiuj"
+ " link do przeglądarki:<br><a href=\"" + ctaUrl + "\" style=\"color:#12a764;\">" + ctaUrl + "</a></p>"
+ "</div>"
// Stopka
+ "<div style=\"background:#eef2f6;border-radius:0 0 14px 14px;padding:16px 24px;font-size:12px;color:#6b7f96;text-align:center;\">"
+ "<p style=\"margin:0 0 4px;\"><strong>Polska Lokalnie | SoftSPM</strong></p>"
+ "<p style=\"margin:0;\">Ogłoszenia nieruchomości bez prowizji. Ta wiadomość została wysłana automatycznie - prosimy na nią nie odpowiadać.</p>"
+ "</div>"
+ "</div></body></html>";
}
}
@@ -10,9 +10,14 @@ 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 pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse;
@@ -28,7 +33,7 @@ public class AuthController {
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
public ResponseEntity<RegisterResponse> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
}
@@ -42,6 +47,48 @@ public class AuthController {
return authService.socialLogin(request);
}
// --- Aktywacja konta ---
@PostMapping("/activate")
public ResponseEntity<Void> activate(@Valid @RequestBody TokenRequest request) {
authService.activate(request.token());
return ResponseEntity.noContent().build();
}
@PostMapping("/resend-activation")
public ResponseEntity<Void> resendActivation(@Valid @RequestBody EmailRequest request) {
authService.resendActivation(request.email());
return ResponseEntity.noContent().build();
}
// --- Reset hasla ---
@PostMapping("/forgot-password")
public ResponseEntity<Void> forgotPassword(@Valid @RequestBody EmailRequest request) {
authService.forgotPassword(request.email());
return ResponseEntity.noContent().build();
}
@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
authService.resetPassword(request.token(), request.password());
return ResponseEntity.noContent().build();
}
// --- Weryfikacja telefonu (OTP SMS) ---
@PostMapping("/verify-phone")
public ResponseEntity<Void> verifyPhone(@Valid @RequestBody VerifyPhoneRequest request) {
authService.verifyPhone(request.email(), request.code());
return ResponseEntity.noContent().build();
}
@PostMapping("/resend-phone-otp")
public ResponseEntity<Void> resendPhoneOtp(@Valid @RequestBody EmailRequest request) {
authService.resendPhoneOtp(request.email());
return ResponseEntity.noContent().build();
}
@GetMapping("/me")
public UserResponse me(Authentication authentication) {
return authService.currentUser(authentication.getName());
@@ -1,17 +1,23 @@
package pl.polskalokalnie.auth;
import java.security.SecureRandom;
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;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
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.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse;
@@ -27,12 +33,19 @@ import pl.polskalokalnie.user.UserRepository;
@Service
public class AuthService {
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
private static final int ACTIVATION_TTL_MINUTES = 48 * 60;
private static final int RESET_TTL_MINUTES = 60;
private final UserRepository userRepository;
private final BlockedEmailRepository blockedEmailRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final TextModerationService textModerationService;
private final LeadSyncService leadSyncService;
private final AuthTokenRepository authTokenRepository;
private final AccountEmailService accountEmailService;
private final PhoneVerificationService phoneVerificationService;
public AuthService(
UserRepository userRepository,
@@ -40,7 +53,10 @@ public class AuthService {
PasswordEncoder passwordEncoder,
JwtService jwtService,
TextModerationService textModerationService,
LeadSyncService leadSyncService
LeadSyncService leadSyncService,
AuthTokenRepository authTokenRepository,
AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
@@ -48,9 +64,13 @@ public class AuthService {
this.jwtService = jwtService;
this.textModerationService = textModerationService;
this.leadSyncService = leadSyncService;
this.authTokenRepository = authTokenRepository;
this.accountEmailService = accountEmailService;
this.phoneVerificationService = phoneVerificationService;
}
public AuthResponse register(RegisterRequest request) {
@Transactional
public RegisterResponse register(RegisterRequest request) {
String email = normalizeEmail(request.email());
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
@@ -59,7 +79,7 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje");
}
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip());
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip(), request.address());
AppUser user = new AppUser();
user.setEmail(email);
@@ -68,14 +88,28 @@ public class AuthService {
user.setRole(Role.USER);
user.setProvider(AuthProvider.LOCAL);
user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL);
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
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);
// Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora.
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
user.setVerified(false);
user.setPhoneVerified(false);
AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved);
return buildAuthResponse(saved);
// Wysylka linku aktywacyjnego na e-mail.
String token = issueToken(saved.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
accountEmailService.sendActivation(saved.getEmail(), saved.getFullName(), token);
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru.
boolean phoneVerificationRequired = phone != null;
if (phoneVerificationRequired) {
phoneVerificationService.sendOtp(saved.getEmail(), phone);
}
return new RegisterResponse(saved.getEmail(), phoneVerificationRequired);
}
public AuthResponse login(LoginRequest request) {
@@ -89,10 +123,112 @@ public class AuthService {
if (user.isBlocked()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
}
// Logowanie mozliwe dopiero po aktywacji konta linkiem z e-maila.
if (!user.isVerified()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
}
return buildAuthResponse(user);
}
// --- Aktywacja konta ---
@Transactional
public void activate(String token) {
AuthToken authToken = requireToken(token, TokenPurpose.ACTIVATION);
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setVerified(true);
userRepository.save(user);
authToken.setUsedAt(Instant.now());
authTokenRepository.save(authToken);
}
// Ponowne wyslanie linku aktywacyjnego. Nie ujawniamy, czy konto istnieje.
@Transactional
public void resendActivation(String email) {
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
if (!user.isVerified()) {
String token = issueToken(user.getEmail(), TokenPurpose.ACTIVATION, ACTIVATION_TTL_MINUTES);
accountEmailService.sendActivation(user.getEmail(), user.getFullName(), token);
}
});
}
// --- Reset hasla ---
// Wyslanie linku do resetu hasla. Zawsze zwraca sukces (nie ujawniamy istnienia konta).
@Transactional
public void forgotPassword(String email) {
userRepository.findByEmailIgnoreCase(normalizeEmail(email)).ifPresent(user -> {
String token = issueToken(user.getEmail(), TokenPurpose.PASSWORD_RESET, RESET_TTL_MINUTES);
accountEmailService.sendPasswordReset(user.getEmail(), user.getFullName(), token);
});
}
// Ustawienie nowego hasla. Dotychczasowe haslo dziala az do tego momentu.
@Transactional
public void resetPassword(String token, String newPassword) {
AuthToken authToken = requireToken(token, TokenPurpose.PASSWORD_RESET);
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPasswordHash(passwordEncoder.encode(newPassword));
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
user.setVerified(true);
userRepository.save(user);
authToken.setUsedAt(Instant.now());
authTokenRepository.save(authToken);
}
// --- Weryfikacja telefonu (OTP) ---
public void verifyPhone(String email, String code) {
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
}
public void resendPhoneOtp(String email) {
String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
if (user.getPhone() == null || user.getPhone().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
}
phoneVerificationService.sendOtp(normalized, user.getPhone());
}
// --- Pomocnicze tokeny ---
private String issueToken(String email, TokenPurpose purpose, int ttlMinutes) {
// Uniewazniamy poprzednie, nieuzyte tokeny tego samego przeznaczenia.
authTokenRepository.findByUserEmailAndPurpose(email, purpose).forEach(old -> {
if (old.getUsedAt() == null) {
old.setUsedAt(Instant.now());
authTokenRepository.save(old);
}
});
byte[] bytes = new byte[32];
TOKEN_RANDOM.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
AuthToken authToken = new AuthToken();
authToken.setToken(token);
authToken.setUserEmail(email);
authToken.setPurpose(purpose);
authToken.setExpiresAt(Instant.now().plus(ttlMinutes, ChronoUnit.MINUTES));
authTokenRepository.save(authToken);
return token;
}
private AuthToken requireToken(String token, TokenPurpose purpose) {
AuthToken authToken = authTokenRepository.findByToken(token == null ? "" : token.trim())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link"));
if (authToken.getPurpose() != purpose || !authToken.isUsable()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link wygasł lub został już wykorzystany");
}
return authToken;
}
/**
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
@@ -0,0 +1,91 @@
package pl.polskalokalnie.auth;
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;
/** Jednorazowy token (aktywacja konta / reset hasla) wysylany w linku mailowym. */
@Entity
@Table(name = "auth_tokens")
public class AuthToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String token;
@Column(nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TokenPurpose purpose;
@Column(nullable = false)
private Instant expiresAt;
private Instant usedAt;
@Column(nullable = false, updatable = false)
private Instant createdAt = Instant.now();
public boolean isUsable() {
return usedAt == null && expiresAt != null && expiresAt.isAfter(Instant.now());
}
public Long getId() {
return id;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public TokenPurpose getPurpose() {
return purpose;
}
public void setPurpose(TokenPurpose purpose) {
this.purpose = purpose;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.auth;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByToken(String token);
List<AuthToken> findByUserEmailAndPurpose(String userEmail, TokenPurpose purpose);
}
@@ -0,0 +1,78 @@
package pl.polskalokalnie.auth;
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;
/** Jednorazowy kod SMS (OTP) do potwierdzenia numeru telefonu. Jeden aktywny kod na uzytkownika. */
@Entity
@Table(name = "phone_otps")
public class PhoneOtp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 180)
private String userEmail;
@Column(nullable = false, length = 10)
private String code;
@Column(nullable = false, length = 30)
private String phone;
@Column(nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private int attempts = 0;
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.auth;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PhoneOtpRepository extends JpaRepository<PhoneOtp, Long> {
Optional<PhoneOtp> findByUserEmail(String userEmail);
void deleteByUserEmail(String userEmail);
}
@@ -0,0 +1,95 @@
package pl.polskalokalnie.auth;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
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.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/**
* Potwierdzanie numeru telefonu kodem SMS (OTP). Kod jednorazowy, wazny 10 minut, maks. 5 prob.
* Gdy bramka SMS nie jest skonfigurowana - kod jest logowany (tryb deweloperski).
*/
@Service
public class PhoneVerificationService {
private static final Logger log = LoggerFactory.getLogger(PhoneVerificationService.class);
private static final SecureRandom RANDOM = new SecureRandom();
private static final int MAX_ATTEMPTS = 5;
private static final int TTL_MINUTES = 10;
private final PhoneOtpRepository otpRepository;
private final SmsSender smsSender;
private final MailConfigService mailConfigService;
private final UserRepository userRepository;
public PhoneVerificationService(PhoneOtpRepository otpRepository,
SmsSender smsSender,
MailConfigService mailConfigService,
UserRepository userRepository) {
this.otpRepository = otpRepository;
this.smsSender = smsSender;
this.mailConfigService = mailConfigService;
this.userRepository = userRepository;
}
@Transactional
public void sendOtp(String email, String phone) {
if (phone == null || phone.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak numeru telefonu");
}
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
PhoneOtp otp = otpRepository.findByUserEmail(email).orElseGet(PhoneOtp::new);
otp.setUserEmail(email);
otp.setPhone(phone.trim());
otp.setCode(code);
otp.setExpiresAt(Instant.now().plus(TTL_MINUTES, ChronoUnit.MINUTES));
otp.setAttempts(0);
otpRepository.save(otp);
SmsGatewayConfig sms = mailConfigService.getSms();
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank();
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
if (!configured) {
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
return;
}
var result = smsSender.send(sms, phone.trim(), message);
if (!result.ok()) {
log.warn("[SMS-OTP] Nie udalo sie wyslac kodu do {} ({}). Kod: {}", email, phone, code);
}
}
@Transactional
public void verifyOtp(String email, String code) {
PhoneOtp otp = otpRepository.findByUserEmail(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak kodu do weryfikacji - wyślij kod ponownie"));
if (otp.getExpiresAt().isBefore(Instant.now())) {
throw new ResponseStatusException(HttpStatus.GONE, "Kod wygasł - wyślij nowy kod");
}
if (otp.getAttempts() >= MAX_ATTEMPTS) {
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Przekroczono liczbę prób - wyślij nowy kod");
}
if (code == null || !code.trim().equals(otp.getCode())) {
otp.setAttempts(otp.getAttempts() + 1);
otpRepository.save(otp);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy kod");
}
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPhoneVerified(true);
userRepository.save(user);
otpRepository.delete(otp);
}
}
@@ -36,11 +36,19 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
.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/gus/company").permitAll()
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
.requestMatchers(HttpMethod.POST, "/api/banners/*/click").permitAll()
.requestMatchers(HttpMethod.POST, "/api/payments/autopay/itn").permitAll()
.requestMatchers("/api/promotion/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
.anyRequest().authenticated()
@@ -0,0 +1,7 @@
package pl.polskalokalnie.auth;
/** Przeznaczenie jednorazowego tokenu wysylanego mailem. */
public enum TokenPurpose {
ACTIVATION,
PASSWORD_RESET
}
@@ -0,0 +1,24 @@
package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** Zbior prostych rekordow zadan dla operacji na koncie (aktywacja, reset hasla, OTP). */
public final class AccountRequests {
private AccountRequests() {
}
public record TokenRequest(@NotBlank String token) {
}
public record EmailRequest(@NotBlank @Email String email) {
}
public record ResetPasswordRequest(@NotBlank String token, @NotBlank @Size(min = 8, max = 100) String password) {
}
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
}
}
@@ -11,6 +11,7 @@ public record RegisterRequest(
@NotBlank String fullName,
AccountType accountType,
String phone,
String nip
String nip,
String address
) {
}
@@ -0,0 +1,11 @@
package pl.polskalokalnie.auth.dto;
/**
* Odpowiedz po rejestracji. Konto wymaga aktywacji linkiem e-mail (brak od razu tokenu logowania).
* phoneVerificationRequired = true, gdy podano numer telefonu i wyslano kod SMS do potwierdzenia.
*/
public record RegisterResponse(
String email,
boolean phoneVerificationRequired
) {
}
@@ -23,7 +23,9 @@ public record UserResponse(
String nip,
LocalDate birthDate,
boolean verified,
boolean phoneVerified,
boolean blocked,
int promotionCredits,
Instant createdAt
) {
public static UserResponse from(AppUser user) {
@@ -41,7 +43,9 @@ public record UserResponse(
user.getNip(),
user.getBirthDate(),
user.isVerified(),
user.isPhoneVerified(),
user.isBlocked(),
user.getPromotionCredits(),
user.getCreatedAt()
);
}
@@ -0,0 +1,52 @@
package pl.polskalokalnie.banner;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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;
/** Zarzadzanie banerami/promocjami w panelu admina (/admin/promocje). Wymaga roli ADMIN. */
@RestController
@RequestMapping("/api/admin/banners")
public class AdminBannerController {
private final BannerService bannerService;
public AdminBannerController(BannerService bannerService) {
this.bannerService = bannerService;
}
@GetMapping
public List<BannerResponse> list() {
return bannerService.listAll();
}
@PostMapping
public BannerResponse create(@Valid @RequestBody BannerRequest request) {
return bannerService.create(request);
}
@PutMapping("/{id}")
public BannerResponse update(@PathVariable Long id, @Valid @RequestBody BannerRequest request) {
return bannerService.update(id, request);
}
@PatchMapping("/{id}/toggle")
public BannerResponse toggle(@PathVariable Long id) {
return bannerService.toggle(id);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
bannerService.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,176 @@
package pl.polskalokalnie.banner;
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.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Baner reklamowy/promocyjny przypisany do slotu (miejsca) w serwisie.
* Grafika przechowywana jako data URL (base64) w kolumnie TEXT - jak zdjecia ogloszen.
*/
@Entity
@Table(name = "banners")
public class Banner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private BannerSlot slot;
@Column(nullable = false, length = 160)
private String title;
@Column(length = 400)
private String subtitle;
// Grafika jako data URL (base64). TEXT, bo przekracza limity VARCHAR.
@Column(columnDefinition = "text")
private String imageUrl;
@Column(length = 500)
private String linkUrl;
@Column(length = 60)
private String ctaLabel;
@Column(nullable = false)
private boolean active = true;
// Kolejnosc w obrebie slotu (mniejsza = wyzej/pierwsza).
@Column(nullable = false)
private int position = 0;
// Opcjonalne okno emisji. null = bez ograniczenia.
private Instant startsAt;
private Instant endsAt;
@Column(nullable = false)
private long impressions = 0;
@Column(nullable = false)
private long clicks = 0;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public BannerSlot getSlot() {
return slot;
}
public void setSlot(BannerSlot slot) {
this.slot = slot;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSubtitle() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle = subtitle;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getLinkUrl() {
return linkUrl;
}
public void setLinkUrl(String linkUrl) {
this.linkUrl = linkUrl;
}
public String getCtaLabel() {
return ctaLabel;
}
public void setCtaLabel(String ctaLabel) {
this.ctaLabel = ctaLabel;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
public Instant getStartsAt() {
return startsAt;
}
public void setStartsAt(Instant startsAt) {
this.startsAt = startsAt;
}
public Instant getEndsAt() {
return endsAt;
}
public void setEndsAt(Instant endsAt) {
this.endsAt = endsAt;
}
public long getImpressions() {
return impressions;
}
public void setImpressions(long impressions) {
this.impressions = impressions;
}
public long getClicks() {
return clicks;
}
public void setClicks(long clicks) {
this.clicks = clicks;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,33 @@
package pl.polskalokalnie.banner;
import java.util.List;
import org.springframework.http.ResponseEntity;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Publiczny odczyt aktywnych banerow dla slotu oraz zliczanie klikniec. */
@RestController
@RequestMapping("/api/banners")
public class BannerController {
private final BannerService bannerService;
public BannerController(BannerService bannerService) {
this.bannerService = bannerService;
}
@GetMapping
public List<PublicBannerResponse> bySlot(@RequestParam BannerSlot slot) {
return bannerService.publicBySlot(slot);
}
@PostMapping("/{id}/click")
public ResponseEntity<Void> registerClick(@PathVariable Long id) {
bannerService.registerClick(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,22 @@
package pl.polskalokalnie.banner;
import java.util.List;
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 BannerRepository extends JpaRepository<Banner, Long> {
List<Banner> findByOrderBySlotAscPositionAscIdAsc();
List<Banner> findBySlotAndActiveTrueOrderByPositionAscIdAsc(BannerSlot slot);
@Modifying
@Query("update Banner b set b.impressions = b.impressions + 1 where b.id in :ids")
void incrementImpressions(@Param("ids") List<Long> ids);
@Modifying
@Query("update Banner b set b.clicks = b.clicks + 1 where b.id = :id")
void incrementClicks(@Param("id") Long id);
}
@@ -0,0 +1,20 @@
package pl.polskalokalnie.banner;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
/** Dane wejsciowe tworzenia/edycji banera (panel admina). */
public record BannerRequest(
@NotNull BannerSlot slot,
@NotBlank String title,
String subtitle,
String imageUrl,
String linkUrl,
String ctaLabel,
Boolean active,
Integer position,
Instant startsAt,
Instant endsAt
) {
}
@@ -0,0 +1,28 @@
package pl.polskalokalnie.banner;
import java.time.Instant;
/** Pelna reprezentacja banera dla panelu admina (ze statystykami). */
public record BannerResponse(
Long id,
BannerSlot slot,
String title,
String subtitle,
String imageUrl,
String linkUrl,
String ctaLabel,
boolean active,
int position,
Instant startsAt,
Instant endsAt,
long impressions,
long clicks,
Instant createdAt
) {
public static BannerResponse from(Banner b) {
return new BannerResponse(
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(), b.getImageUrl(),
b.getLinkUrl(), b.getCtaLabel(), b.isActive(), b.getPosition(),
b.getStartsAt(), b.getEndsAt(), b.getImpressions(), b.getClicks(), b.getCreatedAt());
}
}
@@ -0,0 +1,105 @@
package pl.polskalokalnie.banner;
import java.time.Instant;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class BannerService {
private final BannerRepository bannerRepository;
public BannerService(BannerRepository bannerRepository) {
this.bannerRepository = bannerRepository;
}
// --- Panel admina ---
@Transactional(readOnly = true)
public List<BannerResponse> listAll() {
return bannerRepository.findByOrderBySlotAscPositionAscIdAsc().stream()
.map(BannerResponse::from)
.toList();
}
@Transactional
public BannerResponse create(BannerRequest request) {
Banner banner = new Banner();
apply(banner, request);
return BannerResponse.from(bannerRepository.save(banner));
}
@Transactional
public BannerResponse update(Long id, BannerRequest request) {
Banner banner = find(id);
apply(banner, request);
return BannerResponse.from(bannerRepository.save(banner));
}
@Transactional
public BannerResponse toggle(Long id) {
Banner banner = find(id);
banner.setActive(!banner.isActive());
return BannerResponse.from(bannerRepository.save(banner));
}
@Transactional
public void delete(Long id) {
bannerRepository.delete(find(id));
}
// --- Publiczne ---
// Aktywne banery danego slotu, w oknie emisji; przy okazji zliczamy wyswietlenia.
@Transactional
public List<PublicBannerResponse> publicBySlot(BannerSlot slot) {
Instant now = Instant.now();
List<Banner> visible = bannerRepository.findBySlotAndActiveTrueOrderByPositionAscIdAsc(slot).stream()
.filter(b -> b.getStartsAt() == null || !b.getStartsAt().isAfter(now))
.filter(b -> b.getEndsAt() == null || !b.getEndsAt().isBefore(now))
.toList();
if (!visible.isEmpty()) {
bannerRepository.incrementImpressions(visible.stream().map(Banner::getId).toList());
}
return visible.stream().map(PublicBannerResponse::from).toList();
}
@Transactional
public void registerClick(Long id) {
// Cichy no-op dla nieistniejacego id - klikniecia nie moga wywrocic UX.
if (bannerRepository.existsById(id)) {
bannerRepository.incrementClicks(id);
}
}
// --- Wspolne ---
private Banner find(Long id) {
return bannerRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Baner nie istnieje"));
}
private void apply(Banner banner, BannerRequest request) {
banner.setSlot(request.slot());
banner.setTitle(request.title().trim());
banner.setSubtitle(trimToNull(request.subtitle()));
banner.setImageUrl(trimToNull(request.imageUrl()));
banner.setLinkUrl(trimToNull(request.linkUrl()));
banner.setCtaLabel(trimToNull(request.ctaLabel()));
banner.setActive(request.active() == null || request.active());
banner.setPosition(request.position() == null ? 0 : request.position());
banner.setStartsAt(request.startsAt());
banner.setEndsAt(request.endsAt());
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,15 @@
package pl.polskalokalnie.banner;
/**
* Miejsca (sloty) reklamowe w serwisie. Front renderuje baner przypisany do danego slotu.
* HERO - pasek/karta w sekcji hero na stronie glownej.
* HOME_TOP - szeroki baner pod wyszukiwarka na stronie glownej.
* HOME_MIDDLE - baner miedzy sekcjami ofert (polecane / ostatnio ogladane).
* SIDEBAR - baner w bocznej kolumnie (np. karta oferty).
*/
public enum BannerSlot {
HERO,
HOME_TOP,
HOME_MIDDLE,
SIDEBAR
}
@@ -0,0 +1,18 @@
package pl.polskalokalnie.banner;
/** Publiczna, minimalna reprezentacja banera (bez statystyk i harmonogramu). */
public record PublicBannerResponse(
Long id,
BannerSlot slot,
String title,
String subtitle,
String imageUrl,
String linkUrl,
String ctaLabel
) {
public static PublicBannerResponse from(Banner b) {
return new PublicBannerResponse(
b.getId(), b.getSlot(), b.getTitle(), b.getSubtitle(),
b.getImageUrl(), b.getLinkUrl(), b.getCtaLabel());
}
}
@@ -5,11 +5,18 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import pl.polskalokalnie.banner.Banner;
import pl.polskalokalnie.banner.BannerRepository;
import pl.polskalokalnie.banner.BannerSlot;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.promotion.PromotionPackage;
import pl.polskalokalnie.promotion.PromotionPackageRepository;
import pl.polskalokalnie.promotion.PromotionPlan;
import pl.polskalokalnie.promotion.PromotionPlanRepository;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
import pl.polskalokalnie.user.Role;
@@ -20,6 +27,9 @@ public class DataSeeder implements CommandLineRunner {
private final UserRepository userRepository;
private final ListingRepository listingRepository;
private final BannerRepository bannerRepository;
private final PromotionPlanRepository promotionPlanRepository;
private final PromotionPackageRepository promotionPackageRepository;
private final PasswordEncoder passwordEncoder;
private final String adminEmail;
private final String adminPassword;
@@ -28,6 +38,9 @@ public class DataSeeder implements CommandLineRunner {
public DataSeeder(
UserRepository userRepository,
ListingRepository listingRepository,
BannerRepository bannerRepository,
PromotionPlanRepository promotionPlanRepository,
PromotionPackageRepository promotionPackageRepository,
PasswordEncoder passwordEncoder,
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
@Value("${app.admin.password:Admin123!}") String adminPassword,
@@ -35,6 +48,9 @@ public class DataSeeder implements CommandLineRunner {
) {
this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.bannerRepository = bannerRepository;
this.promotionPlanRepository = promotionPlanRepository;
this.promotionPackageRepository = promotionPackageRepository;
this.passwordEncoder = passwordEncoder;
this.adminEmail = adminEmail;
this.adminPassword = adminPassword;
@@ -45,6 +61,74 @@ public class DataSeeder implements CommandLineRunner {
public void run(String... args) {
seedAdmin();
seedDemoListings();
seedBanners();
seedPromotion();
}
private void seedPromotion() {
if (promotionPlanRepository.count() == 0) {
promotionPlanRepository.save(promotionPlan("Wyróżnienie 4 dni", 4, "19.99", 0));
promotionPlanRepository.save(promotionPlan("Wyróżnienie 14 dni", 14, "49.99", 1));
promotionPlanRepository.save(promotionPlan("Wyróżnienie 30 dni", 30, "89.99", 2));
}
if (promotionPackageRepository.count() == 0) {
promotionPackageRepository.save(promotionPackage("Pakiet 5 promowań", 5, "199.99", 0));
promotionPackageRepository.save(promotionPackage("Pakiet 10 promowań", 10, "349.99", 1));
}
}
private PromotionPlan promotionPlan(String name, int days, String price, int position) {
PromotionPlan plan = new PromotionPlan();
plan.setName(name);
plan.setDurationDays(days);
plan.setPrice(new BigDecimal(price));
plan.setActive(true);
plan.setPosition(position);
return plan;
}
private PromotionPackage promotionPackage(String name, int quantity, String price, int position) {
PromotionPackage pack = new PromotionPackage();
pack.setName(name);
pack.setQuantity(quantity);
pack.setPrice(new BigDecimal(price));
pack.setActive(true);
pack.setPosition(position);
return pack;
}
private void seedBanners() {
if (bannerRepository.count() > 0) {
return;
}
bannerRepository.save(banner(BannerSlot.HERO,
"Wyróżnij swoje ogłoszenie",
"Dotrzyj do tysięcy kupujących i najemców - bez prowizji.",
"Dodaj ogłoszenie", "/dodaj-ogloszenie"));
bannerRepository.save(banner(BannerSlot.HOME_TOP,
"Sprawdź wartość swojej nieruchomości",
"Poznaj realistyczną cenę mieszkania w kilka minut.",
"Wyceń mieszkanie", "/wycena"));
bannerRepository.save(banner(BannerSlot.HOME_MIDDLE,
"Planujesz kredyt hipoteczny?",
"Policz zdolność kredytową i sprawdź, na co Cię stać.",
"Sprawdź zdolność", "/kalkulator-zdolnosci"));
bannerRepository.save(banner(BannerSlot.SIDEBAR,
"Zareklamuj się w Polska Lokalnie",
"Zarezerwuj miejsce reklamowe dla swojej firmy.",
"Napisz do nas", "/konto/pomoc"));
}
private Banner banner(BannerSlot slot, String title, String subtitle, String ctaLabel, String linkUrl) {
Banner banner = new Banner();
banner.setSlot(slot);
banner.setTitle(title);
banner.setSubtitle(subtitle);
banner.setCtaLabel(ctaLabel);
banner.setLinkUrl(linkUrl);
banner.setActive(true);
banner.setPosition(0);
return banner;
}
private void seedAdmin() {
@@ -93,6 +177,8 @@ public class DataSeeder implements CommandLineRunner {
listing.setPrice(price);
listing.setArea(area);
listing.setRooms(rooms);
listing.setContactName("Biuro Polska Lokalnie");
listing.setContactPhone("+48 600 100 200");
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
listing.setStatus(status);
return listing;
@@ -0,0 +1,29 @@
package pl.polskalokalnie.gus;
import org.springframework.web.bind.annotation.GetMapping;
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;
/** Panel admina: konfiguracja integracji z GUS (BIR1.1). Wymaga roli ADMIN (/api/admin/**). */
@RestController
@RequestMapping("/api/admin/gus-config")
public class AdminGusConfigController {
private final GusService gusService;
public AdminGusConfigController(GusService gusService) {
this.gusService = gusService;
}
@GetMapping
public GusConfigResponse get() {
return GusConfigResponse.from(gusService.getConfig());
}
@PutMapping
public GusConfigResponse update(@RequestBody GusConfigRequest request) {
return GusConfigResponse.from(gusService.updateConfig(request));
}
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.gus;
/** Dane firmy pobrane z rejestru REGON - tyle, ile uzupelnia formularz rejestracji. */
public record GusCompanyResponse(
String name,
String nip,
String regon,
String address
) {
}
@@ -0,0 +1,50 @@
package pl.polskalokalnie.gus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Konfiguracja integracji z wyszukiwarka REGON (GUS BIR1.1). Pojedynczy rekord (id=1).
* Gdy sandbox=true uzywamy srodowiska testowego i wbudowanego klucza testowego GUS.
*/
@Entity
@Table(name = "gus_config")
public class GusConfig {
@Id
private Long id = 1L;
// Klucz uzytkownika nadany przez GUS - nigdy nie zwracany w odpowiedzi API.
@Column(length = 100)
private String userKey;
// true = srodowisko testowe BIR1.1, false = produkcyjne.
@Column(nullable = false)
private boolean sandbox = true;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUserKey() {
return userKey;
}
public void setUserKey(String userKey) {
this.userKey = userKey;
}
public boolean isSandbox() {
return sandbox;
}
public void setSandbox(boolean sandbox) {
this.sandbox = sandbox;
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.gus;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GusConfigRepository extends JpaRepository<GusConfig, Long> {
}
@@ -0,0 +1,8 @@
package pl.polskalokalnie.gus;
/** Zapis konfiguracji GUS. Puste userKey = zachowaj dotychczasowy klucz. */
public record GusConfigRequest(
String userKey,
Boolean sandbox
) {
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.gus;
/** Odpowiedz konfiguracji - nigdy nie zwraca klucza, tylko flage czy jest ustawiony. */
public record GusConfigResponse(
boolean userKeySet,
boolean sandbox
) {
public static GusConfigResponse from(GusConfig config) {
boolean keySet = config.getUserKey() != null && !config.getUserKey().isBlank();
return new GusConfigResponse(keySet, config.isSandbox());
}
}
@@ -0,0 +1,23 @@
package pl.polskalokalnie.gus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Pobranie danych firmy z rejestru REGON - dostepne publicznie (formularz rejestracji konta firmowego). */
@RestController
@RequestMapping("/api/gus")
public class GusController {
private final GusService gusService;
public GusController(GusService gusService) {
this.gusService = gusService;
}
@GetMapping("/company")
public GusCompanyResponse company(@RequestParam("nip") String nip) {
return gusService.lookupByNip(nip);
}
}
@@ -0,0 +1,283 @@
package pl.polskalokalnie.gus;
import java.io.ByteArrayInputStream;
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.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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;
/**
* Integracja z wyszukiwarka REGON (GUS, usluga BIR1.1). Loguje sie kluczem uzytkownika i wyszukuje
* podmiot po numerze NIP. Sesja (sid) jest wazna okolo godziny - trzymamy ja w pamieci i odnawiamy,
* gdy GUS odpowie pustym wynikiem (tak sygnalizuje nieaktualna sesje).
*/
@Service
public class GusService {
private static final Long SINGLETON_ID = 1L;
private static final String PROD_URL = "https://wyszukiwarkaregon.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
private static final String TEST_URL = "https://wyszukiwarkaregontest.stat.gov.pl/wsBIR/UslugaBIRzewnPubl.svc";
// Klucz testowy udostepniony publicznie przez GUS dla srodowiska testowego.
private static final String TEST_KEY = "abcde12345abcde12345";
private static final String ACTION_PREFIX = "http://CIS/BIR/PUBL/2014/07/IUslugaBIRzewnPubl/";
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
// Usluga odpowiada wiadomoscia MTOM (multipart) - koperte SOAP trzeba wyluskac z opakowania MIME.
private static final Pattern ENVELOPE_START = Pattern.compile("<[A-Za-z0-9]*:?Envelope[\\s>]");
private static final int[] NIP_WEIGHTS = {6, 5, 7, 2, 3, 4, 5, 6, 7};
private final GusConfigRepository repository;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private String sessionId;
private String sessionEndpoint;
private Instant sessionCreatedAt;
public GusService(GusConfigRepository repository) {
this.repository = repository;
}
@Transactional
public GusConfig getConfig() {
return repository.findById(SINGLETON_ID).orElseGet(() -> {
GusConfig config = new GusConfig();
config.setId(SINGLETON_ID);
return repository.save(config);
});
}
@Transactional
public GusConfig updateConfig(GusConfigRequest request) {
GusConfig config = getConfig();
if (request.sandbox() != null) {
config.setSandbox(request.sandbox());
}
// Klucz nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
if (request.userKey() != null && !request.userKey().isBlank()) {
config.setUserKey(request.userKey().trim());
}
resetSession();
return repository.save(config);
}
/** Pobiera dane firmy z rejestru REGON po numerze NIP. */
public GusCompanyResponse lookupByNip(String rawNip) {
String nip = normalizeNip(rawNip);
GusConfig config = getConfig();
String endpoint = config.isSandbox() ? TEST_URL : PROD_URL;
String key = config.isSandbox() ? TEST_KEY : config.getUserKey();
if (key == null || key.isBlank()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Pobieranie danych z GUS nie jest skonfigurowane. Skontaktuj się z administratorem serwisu.");
}
Map<String, String> dane = search(endpoint, key, nip, true);
if (dane.containsKey("ErrorCode")) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
}
return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane));
}
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
private Map<String, String> search(String endpoint, String key, String nip, boolean canRetry) {
String body = """
<ns:DaneSzukajPodmioty>
<ns:pParametryWyszukiwania>
<dat:Nip>%s</dat:Nip>
</ns:pParametryWyszukiwania>
</ns:DaneSzukajPodmioty>""".formatted(nip);
String response = call(endpoint, "DaneSzukajPodmioty", body, session(endpoint, key));
Map<String, String> dane = firstDane(resultOf(response, "DaneSzukajPodmiotyResult"));
if (dane == null) {
if (canRetry) {
resetSession();
return search(endpoint, key, nip, false);
}
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Rejestr REGON nie zwrócił danych. Spróbuj ponownie za chwilę.");
}
return dane;
}
private synchronized String session(String endpoint, String key) {
boolean expired = sessionId == null
|| !endpoint.equals(sessionEndpoint)
|| sessionCreatedAt == null
|| sessionCreatedAt.plus(SESSION_TTL).isBefore(Instant.now());
if (expired) {
String body = "<ns:Zaloguj><ns:pKluczUzytkownika>%s</ns:pKluczUzytkownika></ns:Zaloguj>".formatted(key);
String sid = resultOf(call(endpoint, "Zaloguj", body, null), "ZalogujResult");
if (sid == null || sid.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
"Nie udało się połączyć z rejestrem REGON. Sprawdź klucz dostępowy GUS.");
}
sessionId = sid.trim();
sessionEndpoint = endpoint;
sessionCreatedAt = Instant.now();
}
return sessionId;
}
private synchronized void resetSession() {
sessionId = null;
sessionEndpoint = null;
sessionCreatedAt = null;
}
private String call(String endpoint, String action, String body, String sid) {
String envelope = """
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:wsa="http://www.w3.org/2005/08/addressing" xmlns:ns="http://CIS/BIR/PUBL/2014/07" xmlns:dat="http://CIS/BIR/PUBL/2014/07/DataContract">
<soap:Header>
<wsa:Action>%s%s</wsa:Action>
<wsa:To>%s</wsa:To>
</soap:Header>
<soap:Body>%s</soap:Body>
</soap:Envelope>""".formatted(ACTION_PREFIX, action, endpoint, body);
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(endpoint))
.timeout(Duration.ofSeconds(20))
.header("Content-Type", "application/soap+xml; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(envelope, StandardCharsets.UTF_8));
if (sid != null) {
request.header("sid", sid);
}
try {
HttpResponse<String> response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił błąd (HTTP " + response.statusCode() + ").");
}
return unwrapSoap(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Połączenie z rejestrem REGON zostało przerwane.");
} catch (java.io.IOException e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się połączyć z rejestrem REGON. Spróbuj ponownie za chwilę.");
}
}
// Odcina naglowki i stopke MIME (MTOM), zostawiajac sama koperte SOAP.
private static String unwrapSoap(String body) {
Matcher matcher = ENVELOPE_START.matcher(body);
if (!matcher.find()) {
return body;
}
int end = body.lastIndexOf("Envelope>");
return end < 0 ? body.substring(matcher.start()) : body.substring(matcher.start(), end + "Envelope>".length());
}
// Wynik operacji BIR to XML zagniezdzony jako tekst w kopercie SOAP.
private static String resultOf(String soap, String localName) {
NodeList nodes = parse(soap).getElementsByTagNameNS("*", localName);
return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null;
}
// Zwraca pola pierwszego rekordu <dane> albo null, gdy odpowiedz jest pusta (wygasla sesja).
private static Map<String, String> firstDane(String xml) {
if (xml == null || xml.isBlank()) {
return null;
}
NodeList danes = parse(xml).getElementsByTagName("dane");
if (danes.getLength() == 0) {
return null;
}
Map<String, String> values = new HashMap<>();
NodeList children = danes.item(0).getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child instanceof Element element) {
String value = element.getTextContent().trim();
if (!value.isEmpty()) {
values.put(element.getTagName(), value);
}
}
}
return values;
}
private static Document parse(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.setNamespaceAware(true);
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Rejestr REGON zwrócił nieczytelną odpowiedź.");
}
}
// Adres w jednej linii: "ul. Krucza 208/3, 00-925 Warszawa".
private static String buildAddress(Map<String, String> dane) {
StringBuilder line = new StringBuilder();
String street = dane.get("Ulica");
if (street != null) {
line.append(street);
}
String building = dane.get("NrNieruchomosci");
if (building != null) {
if (line.length() > 0) {
line.append(' ');
}
line.append(building);
String flat = dane.get("NrLokalu");
if (flat != null) {
line.append('/').append(flat);
}
}
String postalCode = formatPostalCode(dane.get("KodPocztowy"));
String city = dane.get("Miejscowosc");
String cityLine = postalCode != null && city != null ? postalCode + " " + city : (postalCode != null ? postalCode : city);
if (cityLine != null) {
if (line.length() > 0) {
line.append(", ");
}
line.append(cityLine);
}
return line.length() > 0 ? line.toString() : null;
}
// Czesc odpowiedzi GUS zawiera kod pocztowy bez myslnika.
private static String formatPostalCode(String code) {
if (code == null) {
return null;
}
String digits = code.replaceAll("\\D", "");
return digits.length() == 5 ? digits.substring(0, 2) + "-" + digits.substring(2) : code;
}
/** NIP bez separatorow, z kontrola sumy - bledny numer odrzucamy bez odpytywania GUS. */
private static String normalizeNip(String rawNip) {
String nip = rawNip == null ? "" : rawNip.replaceAll("\\D", "");
boolean valid = nip.length() == 10;
if (valid) {
int sum = 0;
for (int i = 0; i < NIP_WEIGHTS.length; i++) {
sum += NIP_WEIGHTS[i] * (nip.charAt(i) - '0');
}
valid = sum % 11 == (nip.charAt(9) - '0');
}
if (!valid) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny numer NIP (10 cyfr).");
}
return nip;
}
}
@@ -48,7 +48,8 @@ public record ListingDetailResponse(
String ownerEmail,
ListingStatus status,
Instant createdAt,
Long viewsCount
Long viewsCount,
Instant promotedUntil
) {
public static ListingDetailResponse from(PropertyListing listing) {
return new ListingDetailResponse(
@@ -91,7 +92,8 @@ public record ListingDetailResponse(
listing.getOwnerEmail(),
listing.getStatus(),
listing.getCreatedAt(),
listing.getViewsCount()
listing.getViewsCount(),
listing.getPromotedUntil()
);
}
}
@@ -27,6 +27,7 @@ public record ListingResponse(
String ownerEmail,
ListingStatus status,
Long viewsCount,
Instant promotedUntil,
Instant createdAt
) {
public static ListingResponse from(PropertyListing listing) {
@@ -50,6 +51,7 @@ public record ListingResponse(
listing.getOwnerEmail(),
listing.getStatus(),
listing.getViewsCount(),
listing.getPromotedUntil(),
listing.getCreatedAt()
);
}
@@ -28,13 +28,19 @@ public class ListingService {
this.notificationService = notificationService;
}
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
private static Comparator<PropertyListing> promotedFirst() {
return Comparator.comparing(PropertyListing::isPromoted).reversed()
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
}
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
return listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
.sorted(promotedFirst())
.map(ListingResponse::from)
.toList();
}
@@ -55,7 +61,7 @@ public class ListingService {
public List<ListingResponse> findMine(String ownerEmail) {
return listingRepository.findAll().stream()
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
.sorted(promotedFirst())
.map(ListingResponse::from)
.toList();
}
@@ -173,7 +179,7 @@ public class ListingService {
public List<ListingResponse> findAllForModeration() {
return listingRepository.findAll().stream()
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
.sorted(promotedFirst())
.map(ListingResponse::from)
.toList();
}
@@ -155,6 +155,9 @@ public class PropertyListing {
@Column(nullable = false)
private Long viewsCount = 0L;
// Data wygasniecia promowania (wyroznienia). null = nie promowane.
private Instant promotedUntil;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@@ -481,6 +484,18 @@ public class PropertyListing {
this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount);
}
public Instant getPromotedUntil() {
return promotedUntil;
}
public void setPromotedUntil(Instant promotedUntil) {
this.promotedUntil = promotedUntil;
}
public boolean isPromoted() {
return promotedUntil != null && promotedUntil.isAfter(Instant.now());
}
public Instant getCreatedAt() {
return createdAt;
}
@@ -0,0 +1,29 @@
package pl.polskalokalnie.payment;
import org.springframework.web.bind.annotation.GetMapping;
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;
/** Panel admina: konfiguracja bramki AutoPay. Wymaga roli ADMIN (/api/admin/**). */
@RestController
@RequestMapping("/api/admin/payment-config")
public class AdminPaymentConfigController {
private final PaymentConfigService paymentConfigService;
public AdminPaymentConfigController(PaymentConfigService paymentConfigService) {
this.paymentConfigService = paymentConfigService;
}
@GetMapping
public PaymentConfigResponse get() {
return PaymentConfigResponse.from(paymentConfigService.get());
}
@PutMapping
public PaymentConfigResponse update(@RequestBody PaymentConfigRequest request) {
return PaymentConfigResponse.from(paymentConfigService.update(request));
}
}
@@ -0,0 +1,103 @@
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).
*
* 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.
*/
@Service
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);
}
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;
}
/** 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;
}
String provided = params.get("Hash");
if (provided == null || provided.isBlank()) {
return false;
}
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);
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
} catch (Exception ex) {
throw new IllegalStateException("Nie udalo sie policzyc podpisu platnosci", ex);
}
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,93 @@
package pl.polskalokalnie.payment;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Konfiguracja bramki szybkich platnosci AutoPay (Autopay/Blue Media). Pojedynczy rekord (id=1).
* Gdy wylaczona lub bez danych dostepowych - dziala tryb sandbox (platnosc auto-potwierdzana).
*/
@Entity
@Table(name = "payment_config")
public class PaymentConfig {
@Id
private Long id = 1L;
// ServiceID nadawany przez AutoPay.
@Column(length = 40)
private String serviceId;
// Klucz wspolpracy (sekret) do liczenia hasha - nigdy nie zwracany w odpowiedzi API.
@Column(length = 200)
private String secretKey;
// Separator wartosci przy liczeniu hasha (AutoPay domyslnie "|").
@Column(length = 5)
private String hashSeparator = "|";
// true = srodowisko testowe (testpay.autopay.eu), false = produkcja (pay.autopay.eu).
@Column(nullable = false)
private boolean sandbox = true;
// Gdy false lub brak serviceId/secretKey - platnosci sa symulowane (auto-potwierdzenie).
@Column(nullable = false)
private boolean enabled = false;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getServiceId() {
return serviceId;
}
public void setServiceId(String serviceId) {
this.serviceId = serviceId;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public String getHashSeparator() {
return hashSeparator == null || hashSeparator.isEmpty() ? "|" : hashSeparator;
}
public void setHashSeparator(String hashSeparator) {
this.hashSeparator = hashSeparator;
}
public boolean isSandbox() {
return sandbox;
}
public void setSandbox(boolean sandbox) {
this.sandbox = sandbox;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/** Czy skonfigurowana na tyle, by wywolac realna bramke. W innym wypadku - tryb sandbox. */
public boolean isLive() {
return enabled
&& serviceId != null && !serviceId.isBlank()
&& secretKey != null && !secretKey.isBlank();
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.payment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PaymentConfigRepository extends JpaRepository<PaymentConfig, Long> {
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.payment;
public record PaymentConfigRequest(
String serviceId,
String secretKey,
String hashSeparator,
Boolean sandbox,
Boolean enabled
) {
}
@@ -0,0 +1,15 @@
package pl.polskalokalnie.payment;
/** Odpowiedz konfiguracji - nigdy nie zwraca sekretu, tylko flage czy jest ustawiony. */
public record PaymentConfigResponse(
String serviceId,
boolean secretKeySet,
String hashSeparator,
boolean sandbox,
boolean enabled
) {
public static PaymentConfigResponse from(PaymentConfig c) {
boolean secretSet = c.getSecretKey() != null && !c.getSecretKey().isBlank();
return new PaymentConfigResponse(c.getServiceId(), secretSet, c.getHashSeparator(), c.isSandbox(), c.isEnabled());
}
}
@@ -0,0 +1,53 @@
package pl.polskalokalnie.payment;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentConfigService {
private static final Long SINGLETON_ID = 1L;
private final PaymentConfigRepository repository;
public PaymentConfigService(PaymentConfigRepository repository) {
this.repository = repository;
}
@Transactional
public PaymentConfig get() {
return repository.findById(SINGLETON_ID).orElseGet(() -> {
PaymentConfig config = new PaymentConfig();
config.setId(SINGLETON_ID);
return repository.save(config);
});
}
@Transactional
public PaymentConfig update(PaymentConfigRequest request) {
PaymentConfig config = get();
config.setServiceId(trimToNull(request.serviceId()));
if (request.hashSeparator() != null && !request.hashSeparator().isBlank()) {
config.setHashSeparator(request.hashSeparator().trim());
}
if (request.sandbox() != null) {
config.setSandbox(request.sandbox());
}
if (request.enabled() != null) {
config.setEnabled(request.enabled());
}
// Sekret nadpisujemy tylko, gdy podano nowa wartosc (puste = zachowaj poprzedni).
if (request.secretKey() != null && !request.secretKey().isBlank()) {
config.setSecretKey(request.secretKey().trim());
}
return repository.save(config);
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,83 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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;
/** Panel admina: definiowanie planow i pakietow promowania + historia platnosci. Wymaga roli ADMIN. */
@RestController
@RequestMapping("/api/admin/promotion")
public class AdminPromotionController {
private final PromotionService promotionService;
public AdminPromotionController(PromotionService promotionService) {
this.promotionService = promotionService;
}
@GetMapping("/plans")
public List<PlanResponse> plans() {
return promotionService.allPlans();
}
@PostMapping("/plans")
public PlanResponse createPlan(@Valid @RequestBody PlanRequest request) {
return promotionService.createPlan(request);
}
@PutMapping("/plans/{id}")
public PlanResponse updatePlan(@PathVariable Long id, @Valid @RequestBody PlanRequest request) {
return promotionService.updatePlan(id, request);
}
@PatchMapping("/plans/{id}/toggle")
public PlanResponse togglePlan(@PathVariable Long id) {
return promotionService.togglePlan(id);
}
@DeleteMapping("/plans/{id}")
public ResponseEntity<Void> deletePlan(@PathVariable Long id) {
promotionService.deletePlan(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/packages")
public List<PackageResponse> packages() {
return promotionService.allPackages();
}
@PostMapping("/packages")
public PackageResponse createPackage(@Valid @RequestBody PackageRequest request) {
return promotionService.createPackage(request);
}
@PutMapping("/packages/{id}")
public PackageResponse updatePackage(@PathVariable Long id, @Valid @RequestBody PackageRequest request) {
return promotionService.updatePackage(id, request);
}
@PatchMapping("/packages/{id}/toggle")
public PackageResponse togglePackage(@PathVariable Long id) {
return promotionService.togglePackage(id);
}
@DeleteMapping("/packages/{id}")
public ResponseEntity<Void> deletePackage(@PathVariable Long id) {
promotionService.deletePackage(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/orders")
public List<OrderResponse> orders() {
return promotionService.allOrders();
}
}
@@ -0,0 +1,56 @@
package pl.polskalokalnie.promotion;
import java.util.Map;
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.
*/
@RestController
@RequestMapping("/api/payments/autopay")
public class AutoPayItnController {
private final AutoPayService autoPayService;
private final PaymentConfigService paymentConfigService;
private final PromotionService promotionService;
public AutoPayItnController(AutoPayService autoPayService,
PaymentConfigService paymentConfigService,
PromotionService promotionService) {
this.autoPayService = autoPayService;
this.paymentConfigService = paymentConfigService;
this.promotionService = promotionService;
}
@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");
}
}
@@ -0,0 +1,13 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.constraints.NotNull;
/** Zamowienie promowania: plan (z listingId) albo pakiet. */
public record CheckoutRequest(
@NotNull PromotionKind kind,
Long planId,
Long packageId,
Long listingId,
String method
) {
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.promotion;
/**
* Wynik rozpoczecia platnosci.
* status = "PAID" (tryb sandbox - od razu oplacone) lub "REDIRECT" (przekierowanie na paywall AutoPay).
*/
public record CheckoutResponse(
String status,
Long orderId,
String redirectUrl
) {
public static CheckoutResponse paid(Long orderId) {
return new CheckoutResponse("PAID", orderId, null);
}
public static CheckoutResponse redirect(Long orderId, String url) {
return new CheckoutResponse("REDIRECT", orderId, url);
}
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.constraints.NotNull;
/** Promowanie oferty z wykorzystaniem kredytu (bez platnosci). */
public record CreditPromoteRequest(
@NotNull Long listingId,
@NotNull Long planId
) {
}
@@ -0,0 +1,22 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderResponse(
Long id,
String userEmail,
PromotionKind kind,
String itemName,
BigDecimal amount,
String method,
Long listingId,
OrderStatus status,
Instant createdAt,
Instant paidAt
) {
public static OrderResponse from(PromotionOrder o) {
return new OrderResponse(o.getId(), o.getUserEmail(), o.getKind(), o.getItemName(), o.getAmount(),
o.getMethod(), o.getListingId(), o.getStatus(), o.getCreatedAt(), o.getPaidAt());
}
}
@@ -0,0 +1,8 @@
package pl.polskalokalnie.promotion;
/** Status platnosci za promowanie. */
public enum OrderStatus {
PENDING,
PAID,
FAILED
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
public record PackageRequest(
@NotBlank String name,
@Min(1) int quantity,
@NotNull @PositiveOrZero BigDecimal price,
Boolean active,
Integer position
) {
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
public record PackageResponse(
Long id,
String name,
int quantity,
BigDecimal price,
boolean active,
int position
) {
public static PackageResponse from(PromotionPackage p) {
return new PackageResponse(p.getId(), p.getName(), p.getQuantity(), p.getPrice(), p.isActive(), p.getPosition());
}
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
public record PlanRequest(
@NotBlank String name,
@Min(1) int durationDays,
@NotNull @PositiveOrZero BigDecimal price,
Boolean active,
Integer position
) {
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
public record PlanResponse(
Long id,
String name,
int durationDays,
BigDecimal price,
boolean active,
int position
) {
public static PlanResponse from(PromotionPlan p) {
return new PlanResponse(p.getId(), p.getName(), p.getDurationDays(), p.getPrice(), p.isActive(), p.getPosition());
}
}
@@ -0,0 +1,59 @@
package pl.polskalokalnie.promotion;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.security.core.Authentication;
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;
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
@RestController
@RequestMapping("/api/promotion")
public class PromotionController {
private final PromotionService promotionService;
public PromotionController(PromotionService promotionService) {
this.promotionService = promotionService;
}
@GetMapping("/plans")
public List<PlanResponse> plans() {
return promotionService.activePlans();
}
@GetMapping("/packages")
public List<PackageResponse> packages() {
return promotionService.activePackages();
}
@GetMapping("/me")
public CreditsResponse me(Authentication authentication) {
return new CreditsResponse(promotionService.credits(authentication.getName()));
}
@PostMapping("/checkout")
public CheckoutResponse checkout(@Valid @RequestBody CheckoutRequest request, Authentication authentication) {
return promotionService.checkout(authentication.getName(), request);
}
@PostMapping("/promote-with-credit")
public CreditsResponse promoteWithCredit(@Valid @RequestBody CreditPromoteRequest request, Authentication authentication) {
return new CreditsResponse(promotionService.promoteWithCredit(authentication.getName(), request));
}
@GetMapping("/order/{id}")
public OrderStatusResponse orderStatus(@PathVariable Long id, Authentication authentication) {
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
}
public record CreditsResponse(int credits) {
}
public record OrderStatusResponse(String status) {
}
}
@@ -0,0 +1,7 @@
package pl.polskalokalnie.promotion;
/** Rodzaj zamowienia promowania: pojedynczy plan (wyroznienie oferty) albo pakiet kredytow. */
public enum PromotionKind {
PLAN,
PACKAGE
}
@@ -0,0 +1,153 @@
package pl.polskalokalnie.promotion;
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.PrePersist;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/** Zamowienie/platnosc za promowanie (plan lub pakiet). Sluzy tez jako historia platnosci. */
@Entity
@Table(name = "promotion_orders")
public class PromotionOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Unikalny identyfikator przekazywany do bramki (OrderID w AutoPay).
@Column(nullable = false, unique = true, length = 40)
private String externalId;
@Column(nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PromotionKind kind;
// Id planu lub pakietu.
private Long refId;
@Column(length = 160)
private String itemName;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(length = 60)
private String method;
// Dla planu: promowana oferta.
private Long listingId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.PENDING;
@Column(nullable = false, updatable = false)
private Instant createdAt;
private Instant paidAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public PromotionKind getKind() {
return kind;
}
public void setKind(PromotionKind kind) {
this.kind = kind;
}
public Long getRefId() {
return refId;
}
public void setRefId(Long refId) {
this.refId = refId;
}
public String getItemName() {
return itemName;
}
public void setItemName(String itemName) {
this.itemName = itemName;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public OrderStatus getStatus() {
return status;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getPaidAt() {
return paidAt;
}
public void setPaidAt(Instant paidAt) {
this.paidAt = paidAt;
}
}
@@ -0,0 +1,10 @@
package pl.polskalokalnie.promotion;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
Optional<PromotionOrder> findByExternalId(String externalId);
List<PromotionOrder> findByOrderByCreatedAtDesc();
}
@@ -0,0 +1,94 @@
package pl.polskalokalnie.promotion;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/** Pakiet promowan definiowany przez admina: liczba kredytow (sztuk) w cenie promocyjnej. */
@Entity
@Table(name = "promotion_packages")
public class PromotionPackage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private int position = 0;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.promotion;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionPackageRepository extends JpaRepository<PromotionPackage, Long> {
List<PromotionPackage> findByOrderByPositionAscIdAsc();
List<PromotionPackage> findByActiveTrueOrderByPositionAscIdAsc();
}
@@ -0,0 +1,94 @@
package pl.polskalokalnie.promotion;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/** Plan promowania definiowany przez admina: czas trwania (dni) i cena. */
@Entity
@Table(name = "promotion_plans")
public class PromotionPlan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false)
private int durationDays;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private int position = 0;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getDurationDays() {
return durationDays;
}
public void setDurationDays(int durationDays) {
this.durationDays = durationDays;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.promotion;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PromotionPlanRepository extends JpaRepository<PromotionPlan, Long> {
List<PromotionPlan> findByOrderByPositionAscIdAsc();
List<PromotionPlan> findByActiveTrueOrderByPositionAscIdAsc();
}
@@ -0,0 +1,290 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
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.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;
@Service
public class PromotionService {
private final PromotionPlanRepository planRepository;
private final PromotionPackageRepository packageRepository;
private final PromotionOrderRepository orderRepository;
private final UserRepository userRepository;
private final ListingRepository listingRepository;
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final String baseUrl;
public PromotionService(PromotionPlanRepository planRepository,
PromotionPackageRepository packageRepository,
PromotionOrderRepository orderRepository,
UserRepository userRepository,
ListingRepository listingRepository,
PaymentConfigService paymentConfigService,
AutoPayService autoPayService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.planRepository = planRepository;
this.packageRepository = packageRepository;
this.orderRepository = orderRepository;
this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
}
// --- Publiczne (uzytkownik) ---
public List<PlanResponse> activePlans() {
return planRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
}
public List<PackageResponse> activePackages() {
return packageRepository.findByActiveTrueOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
}
public int credits(String email) {
return userRepository.findByEmailIgnoreCase(email).map(AppUser::getPromotionCredits).orElse(0);
}
@Transactional
public CheckoutResponse checkout(String email, CheckoutRequest request) {
PromotionOrder order = new PromotionOrder();
order.setUserEmail(email);
order.setKind(request.kind());
order.setExternalId(generateExternalId());
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"));
PropertyListing listing = requireOwnedListing(request.listingId(), email);
order.setRefId(plan.getId());
order.setItemName("Promowanie: " + plan.getName());
order.setAmount(plan.getPrice());
order.setListingId(listing.getId());
} else {
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
order.setRefId(pack.getId());
order.setItemName("Pakiet promowan: " + pack.getName());
order.setAmount(pack.getPrice());
}
order = orderRepository.save(order);
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) {
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
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);
}
@Transactional
public void applyPaid(PromotionOrder order) {
if (order.getStatus() == OrderStatus.PAID) {
return;
}
if (order.getKind() == PromotionKind.PLAN) {
PromotionPlan plan = planRepository.findById(order.getRefId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PropertyListing listing = listingRepository.findById(order.getListingId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing);
} else {
PromotionPackage pack = packageRepository.findById(order.getRefId())
.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);
}
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"));
PropertyListing listing = requireOwnedListing(request.listingId(), email);
user.setPromotionCredits(user.getPromotionCredits() - 1);
userRepository.save(user);
extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing);
PromotionOrder order = new PromotionOrder();
order.setUserEmail(email);
order.setKind(PromotionKind.PLAN);
order.setExternalId(generateExternalId());
order.setRefId(plan.getId());
order.setItemName("Promowanie (kredyt): " + plan.getName());
order.setAmount(BigDecimal.ZERO);
order.setMethod("Kredyt promocji");
order.setListingId(listing.getId());
order.setStatus(OrderStatus.PAID);
order.setPaidAt(Instant.now());
orderRepository.save(order);
return user.getPromotionCredits();
}
public String orderStatus(String email, Long id) {
PromotionOrder order = orderRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
if (!order.getUserEmail().equalsIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
}
return order.getStatus().name();
}
// --- Admin ---
public List<PlanResponse> allPlans() {
return planRepository.findByOrderByPositionAscIdAsc().stream().map(PlanResponse::from).toList();
}
public List<PackageResponse> allPackages() {
return packageRepository.findByOrderByPositionAscIdAsc().stream().map(PackageResponse::from).toList();
}
public List<OrderResponse> allOrders() {
return orderRepository.findByOrderByCreatedAtDesc().stream().map(OrderResponse::from).toList();
}
@Transactional
public PlanResponse createPlan(PlanRequest request) {
PromotionPlan plan = new PromotionPlan();
applyPlan(plan, request);
return PlanResponse.from(planRepository.save(plan));
}
@Transactional
public PlanResponse updatePlan(Long id, PlanRequest request) {
PromotionPlan plan = planRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
applyPlan(plan, request);
return PlanResponse.from(planRepository.save(plan));
}
@Transactional
public PlanResponse togglePlan(Long id) {
PromotionPlan plan = planRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
plan.setActive(!plan.isActive());
return PlanResponse.from(planRepository.save(plan));
}
@Transactional
public void deletePlan(Long id) {
planRepository.deleteById(id);
}
@Transactional
public PackageResponse createPackage(PackageRequest request) {
PromotionPackage pack = new PromotionPackage();
applyPackage(pack, request);
return PackageResponse.from(packageRepository.save(pack));
}
@Transactional
public PackageResponse updatePackage(Long id, PackageRequest request) {
PromotionPackage pack = packageRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
applyPackage(pack, request);
return PackageResponse.from(packageRepository.save(pack));
}
@Transactional
public PackageResponse togglePackage(Long id) {
PromotionPackage pack = packageRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
pack.setActive(!pack.isActive());
return PackageResponse.from(packageRepository.save(pack));
}
@Transactional
public void deletePackage(Long id) {
packageRepository.deleteById(id);
}
// --- Pomocnicze ---
private void applyPlan(PromotionPlan plan, PlanRequest request) {
plan.setName(request.name().trim());
plan.setDurationDays(request.durationDays());
plan.setPrice(request.price());
plan.setActive(request.active() == null || request.active());
plan.setPosition(request.position() == null ? 0 : request.position());
}
private void applyPackage(PromotionPackage pack, PackageRequest request) {
pack.setName(request.name().trim());
pack.setQuantity(request.quantity());
pack.setPrice(request.price());
pack.setActive(request.active() == null || request.active());
pack.setPosition(request.position() == null ? 0 : request.position());
}
private void extendPromotion(PropertyListing listing, int days) {
Instant now = Instant.now();
Instant base = listing.getPromotedUntil() != null && listing.getPromotedUntil().isAfter(now)
? listing.getPromotedUntil() : now;
listing.setPromotedUntil(base.plus(days, ChronoUnit.DAYS));
}
private PropertyListing requireOwnedListing(Long listingId, String email) {
if (listingId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak listingId");
}
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Oferta nie istnieje"));
if (listing.getOwnerEmail() == null || !listing.getOwnerEmail().equalsIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "To nie jest Twoja oferta");
}
return listing;
}
private static Long requireId(Long id, String field) {
if (id == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak " + field);
}
return id;
}
private static String generateExternalId() {
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
}
}
@@ -1,7 +1,9 @@
package pl.polskalokalnie.send;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -11,19 +13,21 @@ import org.springframework.stereotype.Component;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
/**
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST
* (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji.
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}).
*
* UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z
* dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac.
* Zgodnie z manualem bramki: POST {@code application/json} z polami {@code api_key}, {@code to},
* {@code message} oraz opcjonalnym {@code creator}. Odpowiedz to JSON
* {@code {"status":"success|error","message":"..."}}. Sukcesem jest HTTP 2xx i {@code status=success};
* przy bledzie zwracamy komunikat z pola {@code message} bramki.
*/
@Component
public class SmsSender {
private static final String PARAM_API_KEY = "apiKey";
private static final String PARAM_CREATOR = "creator";
private static final String PARAM_TO = "to";
private static final String PARAM_MESSAGE = "message";
private final ObjectMapper objectMapper;
public SmsSender(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public SendResult send(SmsGatewayConfig config, String phone, String message) {
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
@@ -33,10 +37,14 @@ public class SmsSender {
try {
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
? config.getTimeoutSeconds() : 10;
String form = PARAM_API_KEY + "=" + enc(config.getApiKey())
+ "&" + PARAM_CREATOR + "=" + enc(config.getCreator())
+ "&" + PARAM_TO + "=" + enc(phone)
+ "&" + PARAM_MESSAGE + "=" + enc(message);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("api_key", config.getApiKey());
payload.put("to", phone == null ? "" : phone.trim());
payload.put("message", message == null ? "" : message);
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
payload.put("creator", creator);
String jsonBody = objectMapper.writeValueAsString(payload);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeout))
@@ -44,24 +52,38 @@ public class SmsSender {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(config.getEndpointUrl()))
.timeout(Duration.ofSeconds(timeout))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String body = response.body() == null ? "" : response.body().trim();
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return SendResult.ok(shorten(body));
JsonNode json = tryParse(body);
String gatewayStatus = json != null && json.hasNonNull("status") ? json.get("status").asText() : null;
String gatewayMessage = json != null && json.hasNonNull("message") ? json.get("message").asText() : null;
boolean http2xx = response.statusCode() >= 200 && response.statusCode() < 300;
if (http2xx && (gatewayStatus == null || gatewayStatus.equalsIgnoreCase("success"))) {
return SendResult.ok(gatewayMessage != null ? gatewayMessage : "SMS dodany do kolejki.");
}
return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode()
+ (body.isEmpty() ? "" : ": " + shorten(body)));
String detail = gatewayMessage != null && !gatewayMessage.isBlank() ? gatewayMessage : shorten(body);
return SendResult.failure("Bramka SMS: " + (detail.isBlank() ? "status " + response.statusCode() : detail));
} catch (Exception ex) {
return SendResult.failure(shorten(ex.getMessage()));
}
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
private JsonNode tryParse(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
return objectMapper.readTree(body);
} catch (Exception ignored) {
return null;
}
}
private static String shorten(String value) {
@@ -65,6 +65,11 @@ public class AppUser {
private LocalDate birthDate;
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
@Column(nullable = false)
@ColumnDefault("0")
private int promotionCredits = 0;
// Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora.
@Column(nullable = false)
@ColumnDefault("false")
@@ -73,6 +78,11 @@ public class AppUser {
@Column(nullable = false)
private boolean blocked = false;
// Czy numer telefonu zostal potwierdzony kodem SMS (OTP).
@Column(nullable = false)
@ColumnDefault("false")
private boolean phoneVerified = false;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@@ -183,6 +193,14 @@ public class AppUser {
this.birthDate = birthDate;
}
public int getPromotionCredits() {
return promotionCredits;
}
public void setPromotionCredits(int promotionCredits) {
this.promotionCredits = promotionCredits;
}
public boolean isVerified() {
return verified;
}
@@ -191,6 +209,14 @@ public class AppUser {
this.verified = verified;
}
public boolean isPhoneVerified() {
return phoneVerified;
}
public void setPhoneVerified(boolean phoneVerified) {
this.phoneVerified = phoneVerified;
}
public boolean isBlocked() {
return blocked;
}