aktuazalizacja profilow,prefernecji,bezpiecznestwa
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user