aktuazalizacja profilow,prefernecji,bezpiecznestwa

This commit is contained in:
2026-08-13 16:18:46 +02:00
parent fbb7ab2d90
commit 571ec05d76
22 changed files with 1339 additions and 151 deletions
@@ -17,6 +17,7 @@ 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;
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPasswordRequest;
import pl.polskalokalnie.auth.dto.AccountRequests.VerifyPhoneRequest;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -123,6 +124,25 @@ public class AuthController {
return ResponseEntity.noContent().build();
}
/**
* Sprawdza samo dotychczasowe haslo. Formularz ustawien odblokowuje pola nowego hasla dopiero
* po poprawnej odpowiedzi - blad 400 oznacza zle haslo.
*/
@PostMapping("/verify-password")
public ResponseEntity<Void> verifyPassword(Authentication authentication, @Valid @RequestBody VerifyPasswordRequest request) {
authService.verifyCurrentPassword(authentication.getName(), request.password());
return ResponseEntity.noContent().build();
}
/** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo (0 = mozna teraz). */
@GetMapping("/password-cooldown")
public PasswordCooldownResponse passwordCooldown(Authentication authentication) {
return new PasswordCooldownResponse(authService.passwordCooldownSeconds(authentication.getName()));
}
public record PasswordCooldownResponse(long secondsLeft) {
}
@DeleteMapping("/me")
public ResponseEntity<Void> deleteMe(Authentication authentication, @Valid @RequestBody DeleteAccountRequest request) {
authService.deleteOwnAccount(authentication.getName(), request.password());
@@ -1,6 +1,7 @@
package pl.polskalokalnie.auth;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
@@ -38,6 +39,8 @@ 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;
// Odstep miedzy kolejnymi zmianami hasla z poziomu ustawien konta.
private static final int PASSWORD_CHANGE_COOLDOWN_MINUTES = 5;
private final UserRepository userRepository;
private final BlockedEmailRepository blockedEmailRepository;
@@ -297,10 +300,19 @@ public class AuthService {
public UserResponse currentUser(String email) {
return userRepository.findByEmailIgnoreCase(email)
.map(UserResponse::from)
.map(this::withAvatar)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
}
/**
* Profil zalogowanego uzytkownika razem ze zdjeciem z ustawien konta. Dzieki temu naglowek,
* panel konta i kazde inne miejsce pokazujace zalogowanego uzytkownika maja zdjecie od razu,
* bez osobnego zapytania o pelne ustawienia.
*/
private UserResponse withAvatar(AppUser user) {
return UserResponse.from(user, userSettingsService.avatarFor(user.getId()));
}
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -334,7 +346,7 @@ public class AuthService {
AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved);
return UserResponse.from(saved);
return withAvatar(saved);
}
/**
@@ -350,6 +362,16 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"To konto loguje się przez zewnętrznego dostawcę i nie ma hasła w serwisie");
}
// Odstep miedzy zmianami pilnuje serwer, a nie licznik w przegladarce - blokada w interfejsie
// jest tylko podpowiedzia i mozna ja obejsc wolajac API wprost.
long waitSeconds = passwordCooldownSeconds(user);
if (waitSeconds > 0) {
throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Hasło można zmieniać co " + PASSWORD_CHANGE_COOLDOWN_MINUTES
+ " minut. Kolejna zmiana będzie możliwa za " + formatWait(waitSeconds) + ".");
}
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
}
@@ -358,6 +380,7 @@ public class AuthService {
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -369,6 +392,51 @@ public class AuthService {
});
}
/**
* Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Formularz ustawien odblokowuje
* pola nowego hasla dopiero, gdy obecne jest poprawne - dzieki temu uzytkownik nie wypelnia
* calego formularza po to, zeby dowiedziec sie, ze pomylil pierwsze pole.
*/
public void verifyCurrentPassword(String email, String password) {
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 (password == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Dotychczasowe hasło jest nieprawidłowe");
}
}
/** Ile sekund zostalo do momentu, w ktorym mozna ponownie zmienic haslo. Zero = mozna od razu. */
public long passwordCooldownSeconds(String email) {
return userRepository.findByEmailIgnoreCase(email)
.map(this::passwordCooldownSeconds)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
}
private long passwordCooldownSeconds(AppUser user) {
Instant last = user.getPasswordChangedAt();
if (last == null) {
return 0;
}
long elapsed = Duration.between(last, Instant.now()).getSeconds();
long total = PASSWORD_CHANGE_COOLDOWN_MINUTES * 60L;
return elapsed >= total ? 0 : total - elapsed;
}
// "4 min 12 s" zamiast "252 s" - komunikat czyta czlowiek, nie licznik.
private static String formatWait(long seconds) {
long minutes = seconds / 60;
long rest = seconds % 60;
if (minutes == 0) {
return rest + " s";
}
return minutes + " min " + rest + " s";
}
/**
* Usuniecie wlasnego konta. Konta administratorow zostawiamy - tak samo jak przy usuwaniu
* z panelu admina - zeby nie dalo sie zlikwidowac ostatniego dostepu do panelu.
@@ -390,7 +458,7 @@ public class AuthService {
}
private AuthResponse buildAuthResponse(AppUser user) {
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
return new AuthResponse(jwtService.generateToken(user), withAvatar(user));
}
private String normalizeEmail(String email) {
@@ -29,6 +29,10 @@ public final class AccountRequests {
) {
}
/** Sprawdzenie dotychczasowego hasla przed odblokowaniem pol nowego hasla. */
public record VerifyPasswordRequest(@NotBlank String password) {
}
/** Usuniecie wlasnego konta - potwierdzane haslem, zeby przejety token nie wystarczyl. */
public record DeleteAccountRequest(@NotBlank String password) {
}
@@ -27,9 +27,16 @@ public record UserResponse(
boolean phoneVerified,
boolean blocked,
int promotionCredits,
Instant createdAt
Instant createdAt,
// Zdjecie profilowe z ustawien konta (data URL) albo null - wtedy interfejs rysuje inicjaly.
String avatarImage
) {
/** Wariant bez zdjecia - dla miejsc, ktore go nie pokazuja (np. lista kont w panelu admina). */
public static UserResponse from(AppUser user) {
return from(user, null);
}
public static UserResponse from(AppUser user, String avatarImage) {
return new UserResponse(
user.getId(),
user.getEmail(),
@@ -48,7 +55,8 @@ public record UserResponse(
user.isPhoneVerified(),
user.isBlocked(),
user.getPromotionCredits(),
user.getCreatedAt()
user.getCreatedAt(),
avatarImage
);
}
}
@@ -84,6 +84,26 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE"
);
// Widocznosc profilu ma juz tylko dwie wartosci - wariant "tylko kontakty" zostal usuniety.
// Konta, ktore go wybraly, chcialy ograniczyc widocznosc, wiec ladujemy je na PRIVATE
// (bardziej restrykcyjnej), a nie na PUBLIC. Kolejnosc jest istotna: najpierw dane,
// potem constraint, ktory tych danych juz zabrania.
jdbcTemplate.execute(
"DO $$ "
+ "BEGIN "
+ " IF to_regclass('public.user_settings') IS NOT NULL THEN "
+ " UPDATE user_settings SET profile_visibility = 'PRIVATE' WHERE profile_visibility = 'CONTACTS'; "
+ " END IF; "
+ "END $$"
);
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_settings DROP CONSTRAINT IF EXISTS user_settings_profile_visibility_check"
);
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_settings ADD CONSTRAINT user_settings_profile_visibility_check "
+ "CHECK (profile_visibility IN ('PUBLIC','PRIVATE'))"
);
// Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
jdbcTemplate.execute(
@@ -49,12 +49,14 @@ public record ListingDetailResponse(
// Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym
// identyfikatorze frontend otwiera jego profil publiczny.
Long ownerId,
// Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly.
String ownerAvatar,
ListingStatus status,
Instant createdAt,
Long viewsCount,
Instant promotedUntil
) {
public static ListingDetailResponse from(PropertyListing listing, Long ownerId) {
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return new ListingDetailResponse(
listing.getId(),
listing.getTitle(),
@@ -94,6 +96,7 @@ public record ListingDetailResponse(
listing.getVirtualTourUrl(),
listing.getOwnerEmail(),
ownerId,
ownerAvatar,
listing.getStatus(),
listing.getCreatedAt(),
listing.getViewsCount(),
@@ -1,8 +1,10 @@
package pl.polskalokalnie.listing;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.net.URI;
import java.net.URISyntaxException;
import org.springframework.http.HttpStatus;
@@ -13,6 +15,7 @@ import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@Service
public class ListingService {
@@ -23,21 +26,50 @@ public class ListingService {
private final TextModerationService textModerationService;
private final NotificationService notificationService;
private final UserRepository userRepository;
private final UserSettingsService userSettingsService;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService, UserRepository userRepository) {
NotificationService notificationService, UserRepository userRepository,
UserSettingsService userSettingsService) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
this.userRepository = userRepository;
this.userSettingsService = userSettingsService;
}
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
private Long ownerIdOf(PropertyListing listing) {
private AppUser ownerOf(PropertyListing listing) {
if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return null;
}
return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).map(AppUser::getId).orElse(null);
return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
}
/**
* Szczegoly ogloszenia razem z danymi wlasciciela: identyfikatorem profilu i jego zdjeciem.
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
private ListingDetailResponse detailOf(PropertyListing listing) {
AppUser owner = ownerOf(listing);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null);
}
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()));
}
/**
* Nazwa miasta bez polskich znakow i wielkosci liter - "lodz" ma znalezc "Łódź". Litera "ł"
* wymaga osobnej podmiany, bo Normalizer jej nie rozklada (to samodzielny znak, a nie "l"
* ze znakiem diakrytycznym).
*/
private static String normalizeCity(String value) {
if (value == null) {
return "";
}
String lower = value.trim().toLowerCase(Locale.ROOT).replace("ł", "l");
return Normalizer.normalize(lower, Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
}
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
@@ -49,7 +81,7 @@ public class ListingService {
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 -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.sorted(promotedFirst())
@@ -78,7 +110,7 @@ public class ListingService {
listing = listingRepository.save(listing);
}
return ListingDetailResponse.from(listing, ownerIdOf(listing));
return detailOf(listing);
}
public List<ListingResponse> findMine(String ownerEmail) {
@@ -134,7 +166,7 @@ public class ListingService {
listing.setStatus(ListingStatus.PENDING);
PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved));
return detailOf(saved);
}
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
@@ -153,7 +185,7 @@ public class ListingService {
// ownerEmail i status pozostaja bez zmian.
PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved));
return detailOf(saved);
}
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
@@ -2,6 +2,7 @@ package pl.polskalokalnie.report;
import java.time.Instant;
import java.util.List;
import pl.polskalokalnie.user.AppUser;
public record ListingReportResponse(
Long id,
@@ -23,7 +24,12 @@ public record ListingReportResponse(
String resolvedByEmail,
String resolutionNote
) {
public static ListingReportResponse from(ListingReport report) {
/**
* Nazwe zglaszajacego bierzemy z jego konta, jesli nadal istnieje - po zmianie imienia
* i nazwiska panel administratora ma pokazywac aktualna wartosc, a nie te sprzed zmiany.
* Nazwa zapisana w zgloszeniu zostaje zapasem dla kont, ktorych juz nie ma.
*/
public static ListingReportResponse from(ListingReport report, AppUser reporterNow) {
return new ListingReportResponse(
report.getId(),
report.getListingId(),
@@ -36,7 +42,9 @@ public record ListingReportResponse(
report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(),
report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(),
report.getReporterEmail(),
report.getReporterName(),
reporterNow != null && reporterNow.getFullName() != null && !reporterNow.getFullName().isBlank()
? reporterNow.getFullName()
: report.getReporterName(),
report.getStatus(),
report.isListingDeleted(),
report.getCreatedAt(),
@@ -46,12 +46,20 @@ public class ListingReportService {
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
report.setStatus(ListingReportStatus.OPEN);
return ListingReportResponse.from(listingReportRepository.save(report));
return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
}
// Konto zglaszajacego, jesli nadal istnieje - stad bierze sie aktualna nazwa w panelu admina.
private AppUser reporterOf(ListingReport report) {
if (report.getReporterEmail() == null || report.getReporterEmail().isBlank()) {
return null;
}
return userRepository.findByEmailIgnoreCase(report.getReporterEmail()).orElse(null);
}
public List<ListingReportResponse> findAllForAdmin() {
return listingReportRepository.findAllByOrderByCreatedAtDesc().stream()
.map(ListingReportResponse::from)
.map(report -> ListingReportResponse.from(report, reporterOf(report)))
.toList();
}
@@ -62,7 +70,7 @@ public class ListingReportService {
report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail);
report.setResolutionNote(trimToNull(note));
return ListingReportResponse.from(listingReportRepository.save(report));
return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
}
@Transactional
@@ -81,7 +89,7 @@ public class ListingReportService {
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu.");
}
return ListingReportResponse.from(listingReportRepository.save(report));
return ListingReportResponse.from(listingReportRepository.save(report), reporterOf(report));
}
private ListingReport requireReport(Long id) {
@@ -2,6 +2,7 @@ package pl.polskalokalnie.report;
import java.time.Instant;
import java.util.List;
import pl.polskalokalnie.user.AppUser;
public record UserReportResponse(
Long id,
@@ -27,10 +28,15 @@ public record UserReportResponse(
String resolvedByEmail,
String resolutionNote
) {
public static UserReportResponse from(UserReport report, boolean reportedUserExists) {
/**
* Nazwy bierzemy z kont, jesli te nadal istnieja - uzytkownik moze zmienic imie i nazwisko
* po zlozeniu zgloszenia, a panel administratora ma pokazywac stan zgodny z kontem. Nazwa
* zapisana w zgloszeniu zostaje jako zapas dla kont, ktorych juz nie ma.
*/
public static UserReportResponse from(UserReport report, AppUser reportedNow, AppUser reporterNow) {
return new UserReportResponse(
report.getId(),
report.getReportedName(),
currentName(reportedNow, report.getReportedName()),
report.getReportedEmail(),
report.getConversationId(),
report.getListingId(),
@@ -48,14 +54,21 @@ public record UserReportResponse(
message.isHasPhoto()))
.toList(),
report.getReporterEmail(),
report.getReporterName(),
currentName(reporterNow, report.getReporterName()),
report.getStatus(),
report.isReportedUserDeleted(),
reportedUserExists,
reportedNow != null,
report.getCreatedAt(),
report.getResolvedAt(),
report.getResolvedByEmail(),
report.getResolutionNote()
);
}
private static String currentName(AppUser account, String storedName) {
if (account == null || account.getFullName() == null || account.getFullName().isBlank()) {
return storedName;
}
return account.getFullName();
}
}
@@ -51,12 +51,12 @@ public class UserReportService {
}
UserReport saved = userReportRepository.save(report);
return UserReportResponse.from(saved, reported != null);
return UserReportResponse.from(saved, reported, findReported(saved.getReporterEmail()));
}
public List<UserReportResponse> findAllForAdmin() {
return userReportRepository.findAllByOrderByCreatedAtDesc().stream()
.map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()) != null))
.map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()), findReported(report.getReporterEmail())))
.toList();
}
@@ -67,7 +67,7 @@ public class UserReportService {
report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail);
report.setResolutionNote(ReportAttachments.trimToNull(note));
return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()) != null);
return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()), findReported(report.getReporterEmail()));
}
/** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */
@@ -92,7 +92,7 @@ public class UserReportService {
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu.");
}
return UserReportResponse.from(userReportRepository.save(report), false);
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
}
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
@@ -91,6 +91,10 @@ public class AppUser {
@Column(nullable = false, updatable = false)
private Instant createdAt;
// Kiedy uzytkownik ostatnio sam zmienil haslo w ustawieniach konta. Na tej podstawie liczymy
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
@@ -241,4 +245,12 @@ public class AppUser {
public Instant getCreatedAt() {
return createdAt;
}
public Instant getPasswordChangedAt() {
return passwordChangedAt;
}
public void setPasswordChangedAt(Instant passwordChangedAt) {
this.passwordChangedAt = passwordChangedAt;
}
}
@@ -1,8 +1,11 @@
package pl.polskalokalnie.user;
/** Kto widzi profil publiczny uzytkownika. */
/**
* Kto widzi profil publiczny uzytkownika. Domyslnie PUBLIC - profil zalozony dzis jest widoczny,
* dopoki wlasciciel sam tego nie zmieni. PRIVATE ukrywa przed innymi wszystko poza informacja,
* ze profil jest prywatny; wlasciciel widzi swoj profil zawsze.
*/
public enum ProfileVisibility {
PUBLIC,
CONTACTS,
PRIVATE
}
@@ -2,6 +2,7 @@ package pl.polskalokalnie.user;
import java.util.List;
import org.springframework.http.HttpStatus;
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.RequestMapping;
@@ -31,32 +32,51 @@ public class PublicProfileController {
}
@GetMapping("/{id}/profile")
public PublicProfileResponse profile(@PathVariable Long id) {
public PublicProfileResponse profile(@PathVariable Long id, Authentication authentication) {
return toResponse(userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")),
authentication);
}
/** Ten sam profil pod adresem z nazwa uzytkownika - polskalokalnie.pl/u/{nick}. */
@GetMapping("/by-nick/{nick}/profile")
public PublicProfileResponse profileByNick(@PathVariable String nick) {
public PublicProfileResponse profileByNick(@PathVariable String nick, Authentication authentication) {
return toResponse(userRepository.findByNickIgnoreCase(nick)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika")),
authentication);
}
private PublicProfileResponse toResponse(AppUser user) {
/**
* Profil prywatny widzi wylacznie jego wlasciciel. Dla kazdego innego konta zwracamy sama
* informacje, ze profil jest prywatny - decyzje o ukryciu podejmuje serwer, bo ukrywanie
* danych dopiero w interfejsie nie chroni przed odczytem wprost z API.
*/
private boolean isHiddenFrom(AppUser owner, UserSettings settings, Authentication authentication) {
if (settings == null || settings.getProfileVisibility() != ProfileVisibility.PRIVATE) {
return false;
}
String viewerEmail = authentication == null ? null : authentication.getName();
return viewerEmail == null || owner.getEmail() == null || !viewerEmail.equalsIgnoreCase(owner.getEmail());
}
private PublicProfileResponse toResponse(AppUser user, Authentication authentication) {
// Opis, zdjecie i widocznosc uzytkownik ustawia w /konto/ustawienia; brak wiersza = ustawienia domyslne.
UserSettings settings = userSettingsRepository.findByUserId(user.getId()).orElse(null);
if (isHiddenFrom(user, settings, authentication)) {
return PublicProfileResponse.privateFor(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(),
settings == null ? null : settings.getCoverImage(),
user.getAccountType(),
user.isVerified(),
user.getCreatedAt(),
@@ -75,7 +95,8 @@ public class PublicProfileController {
listing.getBuildingFloors(),
listing.getCoverPhoto(),
listing.getCreatedAt()))
.toList()
.toList(),
false
);
}
}
@@ -15,12 +15,38 @@ public record PublicProfileResponse(
// Krotki opis "o mnie" z ustawien konta - pole opcjonalne, moze byc null.
String bio,
String avatarImage,
// Zdjecie w tle profilu z ustawien konta - widzi je kazdy, kto oglada profil publiczny.
String coverImage,
AccountType accountType,
boolean verified,
Instant memberSince,
int listingsCount,
List<PublicProfileListing> listings
List<PublicProfileListing> listings,
// Profil ustawiony jako prywatny, ogladany przez kogos innego niz wlasciciel. Pozostale pola
// sa wtedy puste - interfejs pokazuje sama informacje, ze profil jest prywatny.
boolean privateProfile
) {
/**
* Odpowiedz dla profilu prywatnego. Zostaje tylko tozsamosc konta, zeby dalo sie napisac
* "profil uzytkownika X jest prywatny" - bez ogloszen, opisu, zdjecia i daty dolaczenia.
*/
public static PublicProfileResponse privateFor(AppUser user) {
return new PublicProfileResponse(
user.getId(),
user.getFullName(),
user.getNick(),
null,
null,
null,
null,
false,
null,
0,
List.of(),
true
);
}
public record PublicProfileListing(
Long id,
String title,
@@ -0,0 +1,28 @@
package pl.polskalokalnie.user;
/**
* Same preferencje wyszukiwania z ustawien konta - bez opisu i zdjec, ktore niesie pelna
* odpowiedz /api/me/settings. Wyszukiwarka pobiera to przy kazdym wejsciu do aplikacji,
* wiec odpowiedz musi byc lekka; zdjecie w tle potrafi wazyc kilkaset kilobajtow.
*/
public record SearchPreferencesResponse(
String locations,
String propertyType,
Integer budgetMax,
Integer areaMin,
Integer areaMax,
Integer roomsMin,
Integer roomsMax
) {
public static SearchPreferencesResponse from(UserSettings settings) {
return new SearchPreferencesResponse(
settings.getSearchLocations(),
settings.getSearchPropertyType(),
settings.getSearchBudgetMax(),
settings.getSearchAreaMin(),
settings.getSearchAreaMax(),
settings.getSearchRoomsMin(),
settings.getSearchRoomsMax()
);
}
}
@@ -35,6 +35,16 @@ public class UserSettingsController {
return service.update(currentUserId(authentication), request);
}
/**
* Preferencje wyszukiwania w osobnej, lekkiej odpowiedzi. Wyszukiwarka pobiera je przy wejsciu
* do aplikacji, a pelne ustawienia niosa zdjecie profilowe i zdjecie w tle - kilkaset kilobajtow,
* ktorych do wypelnienia filtrow nie potrzeba.
*/
@GetMapping("/search")
public SearchPreferencesResponse searchPreferences(Authentication authentication) {
return service.searchPreferences(currentUserId(authentication));
}
private Long currentUserId(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName())
.map(AppUser::getId)
@@ -35,6 +35,23 @@ public class UserSettingsService {
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
}
/** Same preferencje wyszukiwania - wyszukiwarka nie potrzebuje reszty ustawien. */
public SearchPreferencesResponse searchPreferences(Long userId) {
return SearchPreferencesResponse.from(
repository.findByUserId(userId).orElseGet(() -> UserSettings.defaultsFor(userId)));
}
/**
* Samo zdjecie profilowe - do pokazania obok nazwy uzytkownika w naglowku, na profilu i przy
* ogloszeniu. Zwraca null, gdy uzytkownik zadnego nie ustawil; wtedy interfejs rysuje inicjaly.
* Osobna metoda, bo pelne ustawienia niosa jeszcze zdjecie w tle, ktorego te miejsca nie potrzebuja.
*/
public String avatarFor(Long userId) {
return repository.findByUserId(userId)
.map(UserSettings::getAvatarImage)
.orElse(null);
}
/**
* Zapis czesciowy: null zostawia dotychczasowa wartosc, pusty tekst ja czysci. Dzieki temu
* kazda sekcja formularza moze zapisac wylacznie swoje pola.