zmiany w ustawieniach
This commit is contained in:
@@ -4,12 +4,16 @@ import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.ChangePasswordRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.DeleteAccountRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.EmailRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.ResetPasswordRequest;
|
||||
import pl.polskalokalnie.auth.dto.AccountRequests.TokenRequest;
|
||||
@@ -98,4 +102,30 @@ public class AuthController {
|
||||
public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) {
|
||||
return authService.updateProfile(authentication.getName(), request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propozycja nazwy uzytkownika z imienia i nazwiska. Ustawienia konta pokazuja ja jako gotowa
|
||||
* wartosc - nicka nie wpisuje sie recznie, zeby adres profilu nie stal sie polem do zabawy.
|
||||
*/
|
||||
@GetMapping("/nick-suggestion")
|
||||
public NickSuggestionResponse nickSuggestion(Authentication authentication, @RequestParam(required = false) String fullName) {
|
||||
return new NickSuggestionResponse(authService.suggestNick(authentication.getName(), fullName));
|
||||
}
|
||||
|
||||
public record NickSuggestionResponse(String nick) {
|
||||
}
|
||||
|
||||
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
||||
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/me")
|
||||
public ResponseEntity<Void> deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) {
|
||||
authService.deleteOwnAccount(authentication.getName(), request.password());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||
import pl.polskalokalnie.user.ContactPreference;
|
||||
import pl.polskalokalnie.user.NickService;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
import pl.polskalokalnie.user.UserSettingsService;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
@@ -46,6 +48,8 @@ public class AuthService {
|
||||
private final AuthTokenRepository authTokenRepository;
|
||||
private final AccountEmailService accountEmailService;
|
||||
private final PhoneVerificationService phoneVerificationService;
|
||||
private final NickService nickService;
|
||||
private final UserSettingsService userSettingsService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
@@ -56,7 +60,9 @@ public class AuthService {
|
||||
LeadSyncService leadSyncService,
|
||||
AuthTokenRepository authTokenRepository,
|
||||
AccountEmailService accountEmailService,
|
||||
PhoneVerificationService phoneVerificationService
|
||||
PhoneVerificationService phoneVerificationService,
|
||||
NickService nickService,
|
||||
UserSettingsService userSettingsService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
@@ -67,6 +73,8 @@ public class AuthService {
|
||||
this.authTokenRepository = authTokenRepository;
|
||||
this.accountEmailService = accountEmailService;
|
||||
this.phoneVerificationService = phoneVerificationService;
|
||||
this.nickService = nickService;
|
||||
this.userSettingsService = userSettingsService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -84,6 +92,9 @@ public class AuthService {
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail(email);
|
||||
user.setFullName(request.fullName().trim());
|
||||
// Nick nadaje system z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"), a przy kolizji
|
||||
// dokleja numer. Uzytkownik nie wymysla go w formularzu rejestracji.
|
||||
user.setNick(nickService.generateFromName(request.fullName(), email, null));
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setRole(Role.USER);
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
@@ -103,13 +114,15 @@ public class AuthService {
|
||||
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);
|
||||
// Gdy podano telefon - od razu wysylamy kod SMS do potwierdzenia numeru. Konto jest juz
|
||||
// zalozone, wiec awaria bramki nie moze przerwac rejestracji; zglaszamy tylko, ze kodu nie ma
|
||||
// co wpisywac, a uzytkownik potwierdzi numer pozniej w ustawieniach konta.
|
||||
boolean codeSent = false;
|
||||
if (phone != null) {
|
||||
codeSent = phoneVerificationService.sendOtp(saved.getEmail(), phone).delivered();
|
||||
}
|
||||
|
||||
return new RegisterResponse(saved.getEmail(), phoneVerificationRequired);
|
||||
return new RegisterResponse(saved.getEmail(), codeSent);
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
@@ -187,6 +200,10 @@ public class AuthService {
|
||||
phoneVerificationService.verifyOtp(normalizeEmail(email), code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recznie zamowiony kod SMS. Tutaj - inaczej niz przy rejestracji - porazka bramki musi dojsc
|
||||
* do uzytkownika, zeby nie czekal na SMS, ktory nigdy nie przyjdzie.
|
||||
*/
|
||||
public void resendPhoneOtp(String email) {
|
||||
String normalized = normalizeEmail(email);
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
||||
@@ -194,7 +211,17 @@ public class AuthService {
|
||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||
}
|
||||
phoneVerificationService.sendOtp(normalized, user.getPhone());
|
||||
if (user.isPhoneVerified()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten numer jest już potwierdzony");
|
||||
}
|
||||
|
||||
PhoneVerificationService.OtpDispatch dispatch = phoneVerificationService.sendOtp(normalized, user.getPhone());
|
||||
switch (dispatch.status()) {
|
||||
case SENT -> { }
|
||||
case GATEWAY_DISABLED -> throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, dispatch.detail());
|
||||
case FAILED -> throw new ResponseStatusException(HttpStatus.BAD_GATEWAY,
|
||||
"Nie udało się wysłać SMS-a: " + (dispatch.detail() == null ? "bramka odrzuciła wysyłkę" : dispatch.detail()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pomocnicze tokeny ---
|
||||
@@ -252,6 +279,7 @@ public class AuthService {
|
||||
AppUser created = new AppUser();
|
||||
created.setEmail(email);
|
||||
created.setFullName(name);
|
||||
created.setNick(nickService.generateFromName(name, email, null));
|
||||
created.setRole(Role.USER);
|
||||
created.setProvider(provider);
|
||||
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
|
||||
@@ -277,10 +305,20 @@ public class AuthService {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.birthDate(), request.address());
|
||||
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address());
|
||||
|
||||
user.setFullName(request.fullName().trim());
|
||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
||||
if (request.nick() != null && !request.nick().isBlank()) {
|
||||
user.setNick(nickService.validateForUser(request.nick(), user));
|
||||
}
|
||||
|
||||
// Zmiana numeru uniewaznia wczesniejsza weryfikacje SMS - inaczej nowy, niepotwierdzony numer
|
||||
// dziedziczylby znaczek "zweryfikowany" po poprzednim.
|
||||
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
||||
if (!java.util.Objects.equals(phone, user.getPhone())) {
|
||||
user.setPhoneVerified(false);
|
||||
}
|
||||
user.setPhone(phone);
|
||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
||||
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
||||
@@ -299,6 +337,58 @@ public class AuthService {
|
||||
return UserResponse.from(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmiana hasla z poziomu ustawien konta. Wymaga dotychczasowego hasla - sam token nie wystarczy,
|
||||
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(String email, String currentPassword, String newPassword) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
if (user.getPasswordHash() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie");
|
||||
}
|
||||
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
|
||||
}
|
||||
if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nowe hasło musi różnić się od dotychczasowego");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
userRepository.save(user);
|
||||
|
||||
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
||||
authTokenRepository.findByUserEmailAndPurpose(user.getEmail(), TokenPurpose.PASSWORD_RESET).forEach(old -> {
|
||||
if (old.getUsedAt() == null) {
|
||||
old.setUsedAt(Instant.now());
|
||||
authTokenRepository.save(old);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu
|
||||
* z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteOwnAccount(String email, String password) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
if (user.getRole() == Role.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konta administratora nie można usunąć z poziomu ustawień");
|
||||
}
|
||||
if (user.getPasswordHash() == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
|
||||
}
|
||||
|
||||
userSettingsService.deleteForUser(user.getId());
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
private AuthResponse buildAuthResponse(AppUser user) {
|
||||
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
|
||||
}
|
||||
@@ -307,6 +397,17 @@ public class AuthService {
|
||||
return email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propozycja nazwy uzytkownika dla podanego imienia i nazwiska. Uzywana przez ustawienia konta:
|
||||
* nicka nie wpisuje sie recznie, tylko przyjmuje gotowa, wolna propozycje systemu.
|
||||
*/
|
||||
public String suggestNick(String email, String fullName) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalizeEmail(email))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName();
|
||||
return nickService.generateFromName(source, user.getEmail(), user);
|
||||
}
|
||||
|
||||
private String defaultSocialEmail(AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> "demo.google@gmail.com";
|
||||
|
||||
@@ -32,10 +32,21 @@ public class PhoneOtp {
|
||||
@Column(nullable = false)
|
||||
private int attempts = 0;
|
||||
|
||||
// Znacznik ostatniej wysylki - pilnuje odstepu miedzy kolejnymi SMS-ami (kazdy kosztuje).
|
||||
private Instant lastSentAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Instant getLastSentAt() {
|
||||
return lastSentAt;
|
||||
}
|
||||
|
||||
public void setLastSentAt(Instant lastSentAt) {
|
||||
this.lastSentAt = lastSentAt;
|
||||
}
|
||||
|
||||
public String getUserEmail() {
|
||||
return userEmail;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import org.slf4j.Logger;
|
||||
@@ -26,6 +27,7 @@ public class PhoneVerificationService {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final int MAX_ATTEMPTS = 5;
|
||||
private static final int TTL_MINUTES = 10;
|
||||
private static final int RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
private final PhoneOtpRepository otpRepository;
|
||||
private final SmsSender smsSender;
|
||||
@@ -42,31 +44,74 @@ public class PhoneVerificationService {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wysyla kod SMS i mowi, co sie z nim naprawde stalo. Wolajacy decyduje, czy porazka ma
|
||||
* przerwac operacje (recznie klikniete "wyslij kod") czy tylko ja oznaczyc (rejestracja,
|
||||
* gdzie konto jest juz zalozone i nie wolno go stracic przez awarie bramki).
|
||||
*/
|
||||
@Transactional
|
||||
public void sendOtp(String email, String phone) {
|
||||
public OtpDispatch 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);
|
||||
|
||||
// Kazdy SMS kosztuje, a endpoint wysylki jest publiczny - trzymamy odstep miedzy wysylkami.
|
||||
Instant lastSentAt = otp.getLastSentAt();
|
||||
if (lastSentAt != null) {
|
||||
long secondsSince = Duration.between(lastSentAt, Instant.now()).getSeconds();
|
||||
if (secondsSince < RESEND_COOLDOWN_SECONDS) {
|
||||
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
|
||||
"Kod został już wysłany. Kolejny możesz zamówić za " + (RESEND_COOLDOWN_SECONDS - secondsSince) + " s");
|
||||
}
|
||||
}
|
||||
|
||||
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||
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.";
|
||||
boolean configured = sms.isEnabled() && sms.getApiKey() != null && !sms.getApiKey().isBlank()
|
||||
&& sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
|
||||
if (!configured) {
|
||||
// Tryb deweloperski: bez bramki kod trafia do logu, ale nie udajemy, ze SMS poszedl.
|
||||
// Nie ustawiamy lastSentAt - skoro nic nie wyszlo, nie ma za co karac odstepem.
|
||||
otpRepository.save(otp);
|
||||
log.info("[SMS-OTP] Bramka SMS nieskonfigurowana. Kod dla {} ({}): {}", email, phone, code);
|
||||
return;
|
||||
return new OtpDispatch(OtpStatus.GATEWAY_DISABLED,
|
||||
"Bramka SMS nie jest włączona - skonfiguruj ją w panelu administratora (Konfiguracja SMS).");
|
||||
}
|
||||
|
||||
// Odstep liczymy od momentu, w ktorym naprawde odpytalismy bramke.
|
||||
otp.setLastSentAt(Instant.now());
|
||||
otpRepository.save(otp);
|
||||
|
||||
String message = "Polska Lokalnie: Twoj kod weryfikacyjny to " + code + ". Wazny 10 minut.";
|
||||
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);
|
||||
// Bez logowania kodu - przy dzialajacej bramce nie ma powodu, zeby trzymac go w logach.
|
||||
log.warn("[SMS-OTP] Bramka odrzucila wysylke do {}: {}", email, result.error());
|
||||
return new OtpDispatch(OtpStatus.FAILED, result.error());
|
||||
}
|
||||
return new OtpDispatch(OtpStatus.SENT, result.providerMessageId());
|
||||
}
|
||||
|
||||
public enum OtpStatus {
|
||||
/** SMS przyjety przez bramke. */
|
||||
SENT,
|
||||
/** Bramka wylaczona lub nieskonfigurowana - kod istnieje, ale nikt go nie dostal. */
|
||||
GATEWAY_DISABLED,
|
||||
/** Bramka odrzucila wysylke. */
|
||||
FAILED
|
||||
}
|
||||
|
||||
public record OtpDispatch(OtpStatus status, String detail) {
|
||||
public boolean delivered() {
|
||||
return status == OtpStatus.SENT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,15 @@ public final class AccountRequests {
|
||||
|
||||
public record VerifyPhoneRequest(@NotBlank @Email String email, @NotBlank String code) {
|
||||
}
|
||||
|
||||
/** Zmiana hasla z poziomu ustawien konta - wymaga podania dotychczasowego hasla. */
|
||||
public record ChangePasswordRequest(
|
||||
@NotBlank String currentPassword,
|
||||
@NotBlank @Size(min = 8, max = 100) String newPassword
|
||||
) {
|
||||
}
|
||||
|
||||
/** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */
|
||||
public record DeleteAccountRequest(@NotBlank String password) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@NotBlank String fullName,
|
||||
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
||||
String nick,
|
||||
String phone,
|
||||
String birthDate,
|
||||
String address,
|
||||
|
||||
@@ -13,6 +13,7 @@ public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String fullName,
|
||||
String nick,
|
||||
Role role,
|
||||
AuthProvider provider,
|
||||
AccountType accountType,
|
||||
@@ -33,6 +34,7 @@ public record UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getFullName(),
|
||||
user.getNick(),
|
||||
user.getRole(),
|
||||
user.getProvider(),
|
||||
user.getAccountType(),
|
||||
|
||||
@@ -19,6 +19,7 @@ import pl.polskalokalnie.promotion.PromotionPlan;
|
||||
import pl.polskalokalnie.promotion.PromotionPlanRepository;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.NickService;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@@ -31,6 +32,7 @@ public class DataSeeder implements CommandLineRunner {
|
||||
private final PromotionPlanRepository promotionPlanRepository;
|
||||
private final PromotionPackageRepository promotionPackageRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final NickService nickService;
|
||||
private final String adminEmail;
|
||||
private final String adminPassword;
|
||||
private final String adminName;
|
||||
@@ -42,6 +44,7 @@ public class DataSeeder implements CommandLineRunner {
|
||||
PromotionPlanRepository promotionPlanRepository,
|
||||
PromotionPackageRepository promotionPackageRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
NickService nickService,
|
||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName
|
||||
@@ -52,6 +55,7 @@ public class DataSeeder implements CommandLineRunner {
|
||||
this.promotionPlanRepository = promotionPlanRepository;
|
||||
this.promotionPackageRepository = promotionPackageRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.nickService = nickService;
|
||||
this.adminEmail = adminEmail;
|
||||
this.adminPassword = adminPassword;
|
||||
this.adminName = adminName;
|
||||
@@ -138,6 +142,7 @@ public class DataSeeder implements CommandLineRunner {
|
||||
AppUser admin = new AppUser();
|
||||
admin.setEmail(adminEmail.toLowerCase());
|
||||
admin.setFullName(adminName);
|
||||
admin.setNick(nickService.generateUnique(adminEmail.split("@")[0]));
|
||||
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
||||
admin.setRole(Role.ADMIN);
|
||||
admin.setProvider(AuthProvider.LOCAL);
|
||||
|
||||
@@ -41,6 +41,30 @@ public class SchemaFixer {
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL"
|
||||
);
|
||||
// Publiczna nazwa uzytkownika. Istniejacym kontom nadajemy ja z czesci adresu e-mail przed @,
|
||||
// z numerem na koncu przy kolizji - dopiero po wypelnieniu wszystkich wierszy zakladamy UNIQUE.
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ADD COLUMN IF NOT EXISTS nick VARCHAR(40)"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"UPDATE app_users u SET nick = base.candidate || CASE WHEN base.rn = 1 THEN '' ELSE base.rn::text END "
|
||||
+ "FROM ( "
|
||||
+ " SELECT id, candidate, ROW_NUMBER() OVER (PARTITION BY candidate ORDER BY id) AS rn FROM ( "
|
||||
+ " SELECT id, COALESCE(NULLIF(regexp_replace(lower(split_part(email, '@', 1)), '[^a-z0-9._-]', '', 'g'), ''), 'uzytkownik') AS candidate "
|
||||
+ " FROM app_users WHERE nick IS NULL "
|
||||
+ " ) normalized "
|
||||
+ ") base "
|
||||
+ "WHERE u.id = base.id AND u.nick IS NULL"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS app_users_nick_key ON app_users (nick)"
|
||||
);
|
||||
|
||||
// Odstep miedzy kolejnymi SMS-ami z kodem - kolumna dochodzi do istniejacej tabeli.
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE IF EXISTS phone_otps ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP"
|
||||
);
|
||||
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)"
|
||||
);
|
||||
|
||||
@@ -38,9 +38,14 @@ public class SmsSender {
|
||||
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
|
||||
? config.getTimeoutSeconds() : 10;
|
||||
|
||||
String recipient = normalizePhone(phone);
|
||||
if (recipient.isBlank()) {
|
||||
return SendResult.failure("Pusty numer telefonu odbiorcy");
|
||||
}
|
||||
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("api_key", config.getApiKey());
|
||||
payload.put("to", phone == null ? "" : phone.trim());
|
||||
payload.put("to", recipient);
|
||||
payload.put("message", message == null ? "" : message);
|
||||
String creator = config.getCreator() == null || config.getCreator().isBlank() ? "API" : config.getCreator();
|
||||
payload.put("creator", creator);
|
||||
@@ -75,6 +80,20 @@ public class SmsSender {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Numery trzymamy w postaci czytelnej dla czlowieka ("+48 601 234 567"), a bramka oczekuje
|
||||
* samego numeru bez spacji i separatorow. Zostawiamy wiodacy plus i cyfry.
|
||||
*/
|
||||
static String normalizePhone(String phone) {
|
||||
if (phone == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = phone.trim();
|
||||
boolean international = trimmed.startsWith("+");
|
||||
String digits = trimmed.replaceAll("\\D", "");
|
||||
return digits.isEmpty() ? "" : (international ? "+" + digits : digits);
|
||||
}
|
||||
|
||||
private JsonNode tryParse(String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
return null;
|
||||
|
||||
@@ -27,6 +27,11 @@ public class AppUser {
|
||||
@Column(length = 120)
|
||||
private String fullName;
|
||||
|
||||
// Publiczna nazwa uzytkownika (polskalokalnie.pl/u/{nick}). Nadawana automatycznie przy rejestracji
|
||||
// z czesci adresu e-mail przed @, pozniej zmienialna w ustawieniach konta. Unikalna w skali serwisu.
|
||||
@Column(unique = true, length = 40)
|
||||
private String nick;
|
||||
|
||||
// Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla.
|
||||
@Column(length = 100)
|
||||
private String passwordHash;
|
||||
@@ -113,6 +118,14 @@ public class AppUser {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getNick() {
|
||||
return nick;
|
||||
}
|
||||
|
||||
public void setNick(String nick) {
|
||||
this.nick = nick;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
/** Jednostka powierzchni: metry kwadratowe albo stopy kwadratowe. */
|
||||
public enum AreaUnit {
|
||||
M2,
|
||||
FT2
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
/** Waluta, w ktorej uzytkownik chce widziec ceny ofert. */
|
||||
public enum Currency {
|
||||
PLN,
|
||||
EUR,
|
||||
USD
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Nadawanie i walidacja publicznej nazwy uzytkownika (nick).
|
||||
*
|
||||
* Nick powstaje automatycznie przy zakladaniu konta - z czesci adresu e-mail przed @ - zeby uzytkownik
|
||||
* nie musial go wymyslac przy rejestracji, a ustawienia konta mialy od razu wypelniona wartosc.
|
||||
* Uzytkownik moze go pozniej zmienic; unikalnosc pilnuje kolumna z indeksem UNIQUE i sprawdzenie tutaj.
|
||||
*/
|
||||
@Service
|
||||
public class NickService {
|
||||
|
||||
public static final int MIN_LENGTH = 3;
|
||||
public static final int MAX_LENGTH = 40;
|
||||
|
||||
/**
|
||||
* Nazwy zastrzezone - nick jest adresem profilu, wiec nie moze sugerowac, ze konto nalezy
|
||||
* do serwisu albo jego obslugi. Bez tego dowolna osoba moze zostac "administrator".
|
||||
*/
|
||||
private static final Set<String> RESERVED = Set.of(
|
||||
"admin", "administrator", "administracja", "moderator", "moderacja",
|
||||
"polskalokalnie", "polska-lokalnie", "polska.lokalnie", "pl",
|
||||
"biuro", "kontakt", "pomoc", "support", "obsluga", "bok",
|
||||
"serwis", "system", "root", "null", "undefined",
|
||||
"ustawienia", "konto", "profil", "oferta", "ogloszenie", "wiadomosci");
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public NickService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zamienia dowolny tekst na dozwolony ksztalt nicka: male litery bez polskich znakow, cyfry,
|
||||
* kropka, myslnik i podkreslnik. Pusty wynik oznacza, ze z podanego tekstu nie da sie zbudowac nicka.
|
||||
*/
|
||||
public String normalize(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String withoutDiacritics = Normalizer.normalize(raw.trim(), Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
|
||||
.replace("ł", "l")
|
||||
.replace("Ł", "L");
|
||||
String cleaned = withoutDiacritics.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9._-]", "")
|
||||
// Powtorzone separatory zwijamy do jednego: "jan..kowalski" i "jan.kowalski" wygladaja
|
||||
// niemal identycznie, a to nazwa publiczna - takie pary uzywa sie do podszywania sie.
|
||||
.replaceAll("[._-]{2,}", ".")
|
||||
.replaceAll("^[._-]+", "")
|
||||
.replaceAll("[._-]+$", "");
|
||||
return cleaned.length() > MAX_LENGTH ? cleaned.substring(0, MAX_LENGTH) : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buduje wolny nick na podstawie propozycji. Przy kolizji dokleja kolejny numer, wiec dwie osoby
|
||||
* o tym samym imieniu i nazwisku dostana rozne nicki.
|
||||
*/
|
||||
public String generateUnique(String proposal) {
|
||||
return generateUnique(proposal, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jak wyzej, ale nick nalezacy do {@code owner} nie jest traktowany jako zajety - dzieki temu
|
||||
* propozycja dla wlasnego konta nie dostaje niepotrzebnego sufiksu.
|
||||
*/
|
||||
public String generateUnique(String proposal, AppUser owner) {
|
||||
String base = normalize(proposal);
|
||||
if (base.length() < MIN_LENGTH) {
|
||||
base = ("uzytkownik" + base);
|
||||
base = base.length() > MAX_LENGTH ? base.substring(0, MAX_LENGTH) : base;
|
||||
}
|
||||
if (isFree(base, owner)) {
|
||||
return base;
|
||||
}
|
||||
for (int suffix = 2; suffix < 10_000; suffix++) {
|
||||
String candidate = withSuffix(base, suffix);
|
||||
if (isFree(candidate, owner)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Nie udało się nadać nazwy użytkownika");
|
||||
}
|
||||
|
||||
/**
|
||||
* Nick z imienia i nazwiska ("Jan Kowalski" -> "jan.kowalski"). Gdy z nazwy nie da sie zbudowac
|
||||
* sensownego nicka (np. konto spoleczne bez nazwy), wraca do czesci adresu e-mail przed @.
|
||||
*/
|
||||
public String generateFromName(String fullName, String email, AppUser owner) {
|
||||
String fromName = normalize(joinNameParts(fullName));
|
||||
if (fromName.length() >= MIN_LENGTH && !RESERVED.contains(fromName)) {
|
||||
return generateUnique(fromName, owner);
|
||||
}
|
||||
int at = email == null ? -1 : email.indexOf('@');
|
||||
String localPart = at > 0 ? email.substring(0, at) : email;
|
||||
return generateUnique(localPart, owner);
|
||||
}
|
||||
|
||||
private boolean isFree(String candidate, AppUser owner) {
|
||||
if (RESERVED.contains(candidate)) {
|
||||
return false;
|
||||
}
|
||||
return userRepository.findByNickIgnoreCase(candidate)
|
||||
.map(existing -> owner != null && existing.getId().equals(owner.getId()))
|
||||
.orElse(true);
|
||||
}
|
||||
|
||||
// "Jan Maria Kowalski" -> "jan.kowalski": pierwszy i ostatni czlon, kropka miedzy nimi.
|
||||
private String joinNameParts(String fullName) {
|
||||
if (fullName == null || fullName.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String[] parts = fullName.trim().split("\\s+");
|
||||
if (parts.length == 1) {
|
||||
return parts[0];
|
||||
}
|
||||
return parts[0] + "." + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza nick podany przez uzytkownika. Zwraca znormalizowana wartosc albo rzuca bledem
|
||||
* z komunikatem do pokazania w formularzu.
|
||||
*/
|
||||
public String validateForUser(String raw, AppUser owner) {
|
||||
String normalized = normalize(raw);
|
||||
if (normalized.length() < MIN_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Nazwa użytkownika musi mieć co najmniej " + MIN_LENGTH + " znaki (dozwolone: litery, cyfry, kropka, myślnik, podkreślnik)");
|
||||
}
|
||||
if (RESERVED.contains(normalized)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest zastrzeżona");
|
||||
}
|
||||
userRepository.findByNickIgnoreCase(normalized).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(owner.getId())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ta nazwa użytkownika jest już zajęta");
|
||||
}
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String withSuffix(String base, int suffix) {
|
||||
String tail = String.valueOf(suffix);
|
||||
int room = MAX_LENGTH - tail.length();
|
||||
String head = base.length() > room ? base.substring(0, room) : base;
|
||||
return head + tail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
/** Kto widzi profil publiczny uzytkownika. */
|
||||
public enum ProfileVisibility {
|
||||
PUBLIC,
|
||||
CONTACTS,
|
||||
PRIVATE
|
||||
}
|
||||
@@ -21,24 +21,42 @@ public class PublicProfileController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final UserSettingsRepository userSettingsRepository;
|
||||
|
||||
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) {
|
||||
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository,
|
||||
UserSettingsRepository userSettingsRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.userSettingsRepository = userSettingsRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/profile")
|
||||
public PublicProfileResponse profile(@PathVariable Long id) {
|
||||
AppUser user = userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"));
|
||||
return toResponse(userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")));
|
||||
}
|
||||
|
||||
/** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */
|
||||
@GetMapping("/by-nick/{nick}/profile")
|
||||
public PublicProfileResponse profileByNick(@PathVariable String nick) {
|
||||
return toResponse(userRepository.findByNickIgnoreCase(nick)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")));
|
||||
}
|
||||
|
||||
private PublicProfileResponse toResponse(AppUser user) {
|
||||
List<PropertyListing> listings = user.getEmail() == null
|
||||
? List.of()
|
||||
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
||||
|
||||
// Opis i zdjecie profilowe uzytkownik ustawia w /konto/ustawienia; brak wiersza = nic nie ustawil.
|
||||
UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null);
|
||||
|
||||
return new PublicProfileResponse(
|
||||
user.getId(),
|
||||
user.getFullName(),
|
||||
user.getNick(),
|
||||
settings == null ? null : settings.getBio(),
|
||||
settings == null ? null : settings.getAvatarImage(),
|
||||
user.getAccountType(),
|
||||
user.isVerified(),
|
||||
user.getCreatedAt(),
|
||||
|
||||
@@ -11,6 +11,10 @@ import java.util.List;
|
||||
public record PublicProfileResponse(
|
||||
Long id,
|
||||
String fullName,
|
||||
String nick,
|
||||
// Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null.
|
||||
String bio,
|
||||
String avatarImage,
|
||||
AccountType accountType,
|
||||
boolean verified,
|
||||
Instant memberSince,
|
||||
|
||||
@@ -9,6 +9,10 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
|
||||
Optional<AppUser> findByNickIgnoreCase(String nick);
|
||||
|
||||
boolean existsByNickIgnoreCase(String nick);
|
||||
|
||||
Optional<AppUser> findFirstByRole(Role role);
|
||||
|
||||
long countByRole(Role role);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
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 org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
/**
|
||||
* Ustawienia konta zapisywane po stronie serwera - jeden wiersz na uzytkownika.
|
||||
*
|
||||
* Wczesniej te wartosci zyly wylacznie w useState i localStorage przegladarki, wiec ginely po
|
||||
* odswiezeniu strony i roznily sie miedzy urzadzeniami. Zgody marketingowe celowo NIE trafiaja tutaj:
|
||||
* ich jedynym miejscem pozostaje lead (MarketingConsentController), bo to one steruja kampaniami.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_settings")
|
||||
public class UserSettings {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private Long userId;
|
||||
|
||||
// --- Profil publiczny ---
|
||||
|
||||
@Column(length = 160)
|
||||
private String bio;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
@ColumnDefault("'PUBLIC'")
|
||||
private ProfileVisibility profileVisibility = ProfileVisibility.PUBLIC;
|
||||
|
||||
// Zdjecia trzymamy jako data URL - tak samo jak okladki ogloszen (PropertyListing.coverPhoto).
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String avatarImage;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String coverImage;
|
||||
|
||||
// --- Preferencje prezentacji ---
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
@ColumnDefault("'PLN'")
|
||||
private Currency currency = Currency.PLN;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
@ColumnDefault("'M2'")
|
||||
private AreaUnit areaUnit = AreaUnit.M2;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean directOffersOnly = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean hideInactiveOffers = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean saveSearchesOnHome = true;
|
||||
|
||||
// --- Powiadomienia e-mail (zgoda marketingowa jest osobno, na leadzie) ---
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean notifySavedSearches = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean notifyPriceAlerts = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("true")
|
||||
private boolean notifyMessages = true;
|
||||
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean notifyProductNews = false;
|
||||
|
||||
// --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) ---
|
||||
|
||||
@Column(length = 255)
|
||||
private String searchLocations;
|
||||
|
||||
@Column(length = 40)
|
||||
private String searchPropertyType;
|
||||
|
||||
private Integer searchBudgetMax;
|
||||
|
||||
private Integer searchAreaMin;
|
||||
|
||||
private Integer searchAreaMax;
|
||||
|
||||
private Integer searchRoomsMin;
|
||||
|
||||
private Integer searchRoomsMax;
|
||||
|
||||
public static UserSettings defaultsFor(Long userId) {
|
||||
UserSettings settings = new UserSettings();
|
||||
settings.setUserId(userId);
|
||||
return settings;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getBio() {
|
||||
return bio;
|
||||
}
|
||||
|
||||
public void setBio(String bio) {
|
||||
this.bio = bio;
|
||||
}
|
||||
|
||||
public ProfileVisibility getProfileVisibility() {
|
||||
return profileVisibility;
|
||||
}
|
||||
|
||||
public void setProfileVisibility(ProfileVisibility profileVisibility) {
|
||||
this.profileVisibility = profileVisibility;
|
||||
}
|
||||
|
||||
public String getAvatarImage() {
|
||||
return avatarImage;
|
||||
}
|
||||
|
||||
public void setAvatarImage(String avatarImage) {
|
||||
this.avatarImage = avatarImage;
|
||||
}
|
||||
|
||||
public String getCoverImage() {
|
||||
return coverImage;
|
||||
}
|
||||
|
||||
public void setCoverImage(String coverImage) {
|
||||
this.coverImage = coverImage;
|
||||
}
|
||||
|
||||
public Currency getCurrency() {
|
||||
return currency;
|
||||
}
|
||||
|
||||
public void setCurrency(Currency currency) {
|
||||
this.currency = currency;
|
||||
}
|
||||
|
||||
public AreaUnit getAreaUnit() {
|
||||
return areaUnit;
|
||||
}
|
||||
|
||||
public void setAreaUnit(AreaUnit areaUnit) {
|
||||
this.areaUnit = areaUnit;
|
||||
}
|
||||
|
||||
public boolean isDirectOffersOnly() {
|
||||
return directOffersOnly;
|
||||
}
|
||||
|
||||
public void setDirectOffersOnly(boolean directOffersOnly) {
|
||||
this.directOffersOnly = directOffersOnly;
|
||||
}
|
||||
|
||||
public boolean isHideInactiveOffers() {
|
||||
return hideInactiveOffers;
|
||||
}
|
||||
|
||||
public void setHideInactiveOffers(boolean hideInactiveOffers) {
|
||||
this.hideInactiveOffers = hideInactiveOffers;
|
||||
}
|
||||
|
||||
public boolean isSaveSearchesOnHome() {
|
||||
return saveSearchesOnHome;
|
||||
}
|
||||
|
||||
public void setSaveSearchesOnHome(boolean saveSearchesOnHome) {
|
||||
this.saveSearchesOnHome = saveSearchesOnHome;
|
||||
}
|
||||
|
||||
public boolean isNotifySavedSearches() {
|
||||
return notifySavedSearches;
|
||||
}
|
||||
|
||||
public void setNotifySavedSearches(boolean notifySavedSearches) {
|
||||
this.notifySavedSearches = notifySavedSearches;
|
||||
}
|
||||
|
||||
public boolean isNotifyPriceAlerts() {
|
||||
return notifyPriceAlerts;
|
||||
}
|
||||
|
||||
public void setNotifyPriceAlerts(boolean notifyPriceAlerts) {
|
||||
this.notifyPriceAlerts = notifyPriceAlerts;
|
||||
}
|
||||
|
||||
public boolean isNotifyMessages() {
|
||||
return notifyMessages;
|
||||
}
|
||||
|
||||
public void setNotifyMessages(boolean notifyMessages) {
|
||||
this.notifyMessages = notifyMessages;
|
||||
}
|
||||
|
||||
public boolean isNotifyProductNews() {
|
||||
return notifyProductNews;
|
||||
}
|
||||
|
||||
public void setNotifyProductNews(boolean notifyProductNews) {
|
||||
this.notifyProductNews = notifyProductNews;
|
||||
}
|
||||
|
||||
public String getSearchLocations() {
|
||||
return searchLocations;
|
||||
}
|
||||
|
||||
public void setSearchLocations(String searchLocations) {
|
||||
this.searchLocations = searchLocations;
|
||||
}
|
||||
|
||||
public String getSearchPropertyType() {
|
||||
return searchPropertyType;
|
||||
}
|
||||
|
||||
public void setSearchPropertyType(String searchPropertyType) {
|
||||
this.searchPropertyType = searchPropertyType;
|
||||
}
|
||||
|
||||
public Integer getSearchBudgetMax() {
|
||||
return searchBudgetMax;
|
||||
}
|
||||
|
||||
public void setSearchBudgetMax(Integer searchBudgetMax) {
|
||||
this.searchBudgetMax = searchBudgetMax;
|
||||
}
|
||||
|
||||
public Integer getSearchAreaMin() {
|
||||
return searchAreaMin;
|
||||
}
|
||||
|
||||
public void setSearchAreaMin(Integer searchAreaMin) {
|
||||
this.searchAreaMin = searchAreaMin;
|
||||
}
|
||||
|
||||
public Integer getSearchAreaMax() {
|
||||
return searchAreaMax;
|
||||
}
|
||||
|
||||
public void setSearchAreaMax(Integer searchAreaMax) {
|
||||
this.searchAreaMax = searchAreaMax;
|
||||
}
|
||||
|
||||
public Integer getSearchRoomsMin() {
|
||||
return searchRoomsMin;
|
||||
}
|
||||
|
||||
public void setSearchRoomsMin(Integer searchRoomsMin) {
|
||||
this.searchRoomsMin = searchRoomsMin;
|
||||
}
|
||||
|
||||
public Integer getSearchRoomsMax() {
|
||||
return searchRoomsMax;
|
||||
}
|
||||
|
||||
public void setSearchRoomsMax(Integer searchRoomsMax) {
|
||||
this.searchRoomsMax = searchRoomsMax;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Ustawienia konta zalogowanego uzytkownika (/konto/ustawienia). Endpoint wymaga uwierzytelnienia
|
||||
* przez regule anyRequest().authenticated() w SecurityConfig.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/me/settings")
|
||||
public class UserSettingsController {
|
||||
|
||||
private final UserSettingsService service;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserSettingsController(UserSettingsService service, UserRepository userRepository) {
|
||||
this.service = service;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public UserSettingsResponse get(Authentication authentication) {
|
||||
return service.get(currentUserId(authentication));
|
||||
}
|
||||
|
||||
@PutMapping
|
||||
public UserSettingsResponse update(Authentication authentication, @RequestBody UserSettingsRequest request) {
|
||||
return service.update(currentUserId(authentication), request);
|
||||
}
|
||||
|
||||
private Long currentUserId(Authentication authentication) {
|
||||
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||
.map(AppUser::getId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserSettingsRepository extends JpaRepository<UserSettings, Long> {
|
||||
|
||||
Optional<UserSettings> findByUserId(Long userId);
|
||||
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
/**
|
||||
* Zapis ustawien konta. Pola obiektowe (null) oznaczaja "zostaw jak bylo" - dzieki temu pojedyncza
|
||||
* sekcja formularza moze zapisac sie sama, bez przesylania calego stanu strony.
|
||||
*/
|
||||
public record UserSettingsRequest(
|
||||
String bio,
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
Boolean directOffersOnly,
|
||||
Boolean hideInactiveOffers,
|
||||
Boolean saveSearchesOnHome,
|
||||
Boolean notifySavedSearches,
|
||||
Boolean notifyPriceAlerts,
|
||||
Boolean notifyMessages,
|
||||
Boolean notifyProductNews,
|
||||
String searchLocations,
|
||||
String searchPropertyType,
|
||||
Integer searchBudgetMax,
|
||||
Integer searchAreaMin,
|
||||
Integer searchAreaMax,
|
||||
Integer searchRoomsMin,
|
||||
Integer searchRoomsMax
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
/** Komplet ustawien konta odsylany do przegladarki. */
|
||||
public record UserSettingsResponse(
|
||||
String bio,
|
||||
ProfileVisibility profileVisibility,
|
||||
String avatarImage,
|
||||
String coverImage,
|
||||
Currency currency,
|
||||
AreaUnit areaUnit,
|
||||
boolean directOffersOnly,
|
||||
boolean hideInactiveOffers,
|
||||
boolean saveSearchesOnHome,
|
||||
boolean notifySavedSearches,
|
||||
boolean notifyPriceAlerts,
|
||||
boolean notifyMessages,
|
||||
boolean notifyProductNews,
|
||||
String searchLocations,
|
||||
String searchPropertyType,
|
||||
Integer searchBudgetMax,
|
||||
Integer searchAreaMin,
|
||||
Integer searchAreaMax,
|
||||
Integer searchRoomsMin,
|
||||
Integer searchRoomsMax
|
||||
) {
|
||||
public static UserSettingsResponse from(UserSettings settings) {
|
||||
return new UserSettingsResponse(
|
||||
settings.getBio(),
|
||||
settings.getProfileVisibility(),
|
||||
settings.getAvatarImage(),
|
||||
settings.getCoverImage(),
|
||||
settings.getCurrency(),
|
||||
settings.getAreaUnit(),
|
||||
settings.isDirectOffersOnly(),
|
||||
settings.isHideInactiveOffers(),
|
||||
settings.isSaveSearchesOnHome(),
|
||||
settings.isNotifySavedSearches(),
|
||||
settings.isNotifyPriceAlerts(),
|
||||
settings.isNotifyMessages(),
|
||||
settings.isNotifyProductNews(),
|
||||
settings.getSearchLocations(),
|
||||
settings.getSearchPropertyType(),
|
||||
settings.getSearchBudgetMax(),
|
||||
settings.getSearchAreaMin(),
|
||||
settings.getSearchAreaMax(),
|
||||
settings.getSearchRoomsMin(),
|
||||
settings.getSearchRoomsMax()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
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.moderation.TextModerationService;
|
||||
|
||||
/**
|
||||
* Odczyt i zapis ustawien konta. Brak wiersza w bazie oznacza ustawienia domyslne - tworzymy go
|
||||
* dopiero przy pierwszym zapisie, wiec konta, ktore nigdy nic nie zmienialy, nie zasmiecaja tabeli.
|
||||
*/
|
||||
@Service
|
||||
public class UserSettingsService {
|
||||
|
||||
/**
|
||||
* Gorny limit na zdjecie w postaci data URL. Front skaluje obrazy przed wyslaniem (ok. 100-200 KB),
|
||||
* wiec ten limit lapie tylko proby obejscia formularza. Trzymamy go ponizej limitu ciala zadania
|
||||
* w nginx, zeby uzytkownik dostal czytelny blad zamiast 413 z proxy.
|
||||
*/
|
||||
private static final int MAX_IMAGE_DATA_URL_LENGTH = 700_000;
|
||||
|
||||
private static final int MAX_BIO_LENGTH = 160;
|
||||
|
||||
private final UserSettingsRepository repository;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public UserSettingsService(UserSettingsRepository repository, TextModerationService textModerationService) {
|
||||
this.repository = repository;
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
public UserSettingsResponse get(Long userId) {
|
||||
return UserSettingsResponse.from(
|
||||
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu
|
||||
* kazda sekcja formularza moze zapisac wylacznie swoje pola.
|
||||
*/
|
||||
@Transactional
|
||||
public UserSettingsResponse update(Long userId, UserSettingsRequest request) {
|
||||
UserSettings settings = repository.findByUserId(userId)
|
||||
.orElseGet(() -> UserSettings.defaultsFor(userId));
|
||||
|
||||
if (request.bio() != null) {
|
||||
String bio = request.bio().trim();
|
||||
if (bio.length() > MAX_BIO_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Biografia może mieć maksymalnie " + MAX_BIO_LENGTH + " znaków");
|
||||
}
|
||||
textModerationService.validateOrThrow(bio);
|
||||
settings.setBio(bio.isEmpty() ? null : bio);
|
||||
}
|
||||
if (request.profileVisibility() != null) {
|
||||
settings.setProfileVisibility(request.profileVisibility());
|
||||
}
|
||||
if (request.avatarImage() != null) {
|
||||
settings.setAvatarImage(cleanImage(request.avatarImage(), "Zdjęcie profilowe"));
|
||||
}
|
||||
if (request.coverImage() != null) {
|
||||
settings.setCoverImage(cleanImage(request.coverImage(), "Zdjęcie w tle"));
|
||||
}
|
||||
if (request.currency() != null) {
|
||||
settings.setCurrency(request.currency());
|
||||
}
|
||||
if (request.areaUnit() != null) {
|
||||
settings.setAreaUnit(request.areaUnit());
|
||||
}
|
||||
if (request.directOffersOnly() != null) {
|
||||
settings.setDirectOffersOnly(request.directOffersOnly());
|
||||
}
|
||||
if (request.hideInactiveOffers() != null) {
|
||||
settings.setHideInactiveOffers(request.hideInactiveOffers());
|
||||
}
|
||||
if (request.saveSearchesOnHome() != null) {
|
||||
settings.setSaveSearchesOnHome(request.saveSearchesOnHome());
|
||||
}
|
||||
if (request.notifySavedSearches() != null) {
|
||||
settings.setNotifySavedSearches(request.notifySavedSearches());
|
||||
}
|
||||
if (request.notifyPriceAlerts() != null) {
|
||||
settings.setNotifyPriceAlerts(request.notifyPriceAlerts());
|
||||
}
|
||||
if (request.notifyMessages() != null) {
|
||||
settings.setNotifyMessages(request.notifyMessages());
|
||||
}
|
||||
if (request.notifyProductNews() != null) {
|
||||
settings.setNotifyProductNews(request.notifyProductNews());
|
||||
}
|
||||
if (request.searchLocations() != null) {
|
||||
String locations = request.searchLocations().trim();
|
||||
textModerationService.validateOrThrow(locations);
|
||||
settings.setSearchLocations(locations.isEmpty() ? null : locations);
|
||||
}
|
||||
if (request.searchPropertyType() != null) {
|
||||
String type = request.searchPropertyType().trim();
|
||||
settings.setSearchPropertyType(type.isEmpty() ? null : type);
|
||||
}
|
||||
settings.setSearchBudgetMax(pickNumber(request.searchBudgetMax(), settings.getSearchBudgetMax()));
|
||||
settings.setSearchAreaMin(pickNumber(request.searchAreaMin(), settings.getSearchAreaMin()));
|
||||
settings.setSearchAreaMax(pickNumber(request.searchAreaMax(), settings.getSearchAreaMax()));
|
||||
settings.setSearchRoomsMin(pickNumber(request.searchRoomsMin(), settings.getSearchRoomsMin()));
|
||||
settings.setSearchRoomsMax(pickNumber(request.searchRoomsMax(), settings.getSearchRoomsMax()));
|
||||
|
||||
return UserSettingsResponse.from(repository.save(settings));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteForUser(Long userId) {
|
||||
repository.deleteByUserId(userId);
|
||||
}
|
||||
|
||||
// Liczby: wartosc ujemna to sygnal "wyczysc pole" (formularz wysyla -1 dla pustego inputa).
|
||||
private Integer pickNumber(Integer incoming, Integer current) {
|
||||
if (incoming == null) {
|
||||
return current;
|
||||
}
|
||||
return incoming < 0 ? null : incoming;
|
||||
}
|
||||
|
||||
private String cleanImage(String dataUrl, String label) {
|
||||
String trimmed = dataUrl.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (!trimmed.startsWith("data:image/")) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " musi być obrazem przesłanym z formularza");
|
||||
}
|
||||
if (trimmed.length() > MAX_IMAGE_DATA_URL_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, label + " jest za duże. Wybierz mniejszy plik.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pl.polskalokalnie.send;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Numery telefonow trzymamy w bazie w postaci czytelnej dla czlowieka ("+48 601 234 567"),
|
||||
* a bramka SMS oczekuje samego numeru. Bez normalizacji wysylka konczy sie bledem bramki.
|
||||
*/
|
||||
class SmsSenderTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("usuwa spacje i separatory, zachowujac kierunkowy")
|
||||
void normalizujeNumerZeSpacjami() {
|
||||
assertEquals("+48601234567", SmsSender.normalizePhone("+48 601 234 567"));
|
||||
assertEquals("+48601234567", SmsSender.normalizePhone("+48 601234567"));
|
||||
assertEquals("+48601234567", SmsSender.normalizePhone(" +48-601-234-567 "));
|
||||
assertEquals("+48601234567", SmsSender.normalizePhone("+48 (601) 234 567"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("numer bez kierunkowego zostaje bez plusa")
|
||||
void zachowujeNumerKrajowy() {
|
||||
assertEquals("601234567", SmsSender.normalizePhone("601 234 567"));
|
||||
assertEquals("601234567", SmsSender.normalizePhone("601-234-567"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pusty lub bezsensowny numer daje pusty wynik")
|
||||
void pustyNumer() {
|
||||
assertEquals("", SmsSender.normalizePhone(null));
|
||||
assertEquals("", SmsSender.normalizePhone(" "));
|
||||
assertEquals("", SmsSender.normalizePhone("brak numeru"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Normalizacja nazwy uzytkownika. Nick jest czescia adresu profilu (/u/{nick}), wiec musi byc
|
||||
* pozbawiony polskich znakow, spacji i wszystkiego, co psuje URL.
|
||||
*/
|
||||
class NickServiceTest {
|
||||
|
||||
// normalize() nie korzysta z repozytorium - do testu wystarczy instancja bez zaleznosci.
|
||||
private final NickService nickService = new NickService(null);
|
||||
|
||||
@Test
|
||||
@DisplayName("zamienia polskie znaki na odpowiedniki ASCII")
|
||||
void usuwaPolskieZnaki() {
|
||||
assertEquals("zazolc", nickService.normalize("Zażółć"));
|
||||
assertEquals("lakowski", nickService.normalize("Łąkowski"));
|
||||
assertEquals("jaskowalski", nickService.normalize("Jaś Kowalski!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zostawia kropke, mysnik i podkreslnik, wycina reszte")
|
||||
void dopuszczaTylkoBezpieczneZnaki() {
|
||||
assertEquals("jan.kowalski", nickService.normalize("jan.kowalski"));
|
||||
assertEquals("jan-kowalski", nickService.normalize("jan-kowalski"));
|
||||
assertEquals("jan_kowalski", nickService.normalize("jan_kowalski"));
|
||||
assertEquals("jankowalski", nickService.normalize("jan kowalski"));
|
||||
assertEquals("jankowalski.etc", nickService.normalize("jan@kowalski/../etc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zwija powtorzone separatory - inaczej jan..kowalski podszywa sie pod jan.kowalski")
|
||||
void zwijaPowtorzoneSeparatory() {
|
||||
assertEquals("jan.kowalski", nickService.normalize("jan..kowalski"));
|
||||
assertEquals("jan.kowalski", nickService.normalize("jan---kowalski"));
|
||||
assertEquals("jan.kowalski", nickService.normalize("jan_-.kowalski"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("obcina separatory z poczatku i konca")
|
||||
void obcinaSeparatoryNaBrzegach() {
|
||||
assertEquals("kowalski", nickService.normalize("...kowalski---"));
|
||||
assertEquals("kowalski", nickService.normalize("_kowalski_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("nie przekracza dozwolonej dlugosci")
|
||||
void pilnujeDlugosci() {
|
||||
String result = nickService.normalize("a".repeat(200));
|
||||
assertEquals(NickService.MAX_LENGTH, result.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("z tekstu bez liter i cyfr nie da sie zbudowac nicka")
|
||||
void pustyWynikDlaSmieci() {
|
||||
assertEquals("", nickService.normalize(null));
|
||||
assertEquals("", nickService.normalize(" "));
|
||||
assertTrue(nickService.normalize("!@#$%").isEmpty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user