Merge pull request 'Pascal zmiany Alerty cenowe + Pomoc i kontakt' (#1) from pascal-zmiany into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-13 01:22:20 +02:00
11 changed files with 4318 additions and 65 deletions
@@ -2,8 +2,10 @@ package pl.polskalokalnie.admin;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.security.SecureRandom;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -11,9 +13,11 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import pl.polskalokalnie.moderation.ForbiddenWordResponse; import pl.polskalokalnie.moderation.ForbiddenWordResponse;
import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.UserResponse; import pl.polskalokalnie.auth.dto.UserResponse;
@@ -36,23 +40,33 @@ import pl.polskalokalnie.user.UserRepository;
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
public class AdminController { public class AdminController {
private static final String PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%";
private static final int TEMP_PASSWORD_LENGTH = 12;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final UserRepository userRepository; private final UserRepository userRepository;
private final BlockedEmailRepository blockedEmailRepository; private final BlockedEmailRepository blockedEmailRepository;
private final ListingService listingService; private final ListingService listingService;
private final ListingReportService listingReportService; private final ListingReportService listingReportService;
private final MessageService messageService; private final MessageService messageService;
private final TextModerationService textModerationService; private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService, ListingService listingService, ListingReportService listingReportService,
MessageService messageService, MessageService messageService,
TextModerationService textModerationService) { TextModerationService textModerationService,
AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository; this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService; this.listingService = listingService;
this.listingReportService = listingReportService; this.listingReportService = listingReportService;
this.messageService = messageService; this.messageService = messageService;
this.textModerationService = textModerationService; this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder;
} }
// --- Uzytkownicy --- // --- Uzytkownicy ---
@@ -82,6 +96,50 @@ public class AdminController {
return UserResponse.from(userRepository.save(user)); return UserResponse.from(userRepository.save(user));
} }
@PostMapping("/users/{id}/grant-admin")
public UserResponse grantAdmin(@PathVariable Long id) {
AppUser user = requireUser(id);
if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik już ma rolę administratora");
}
user.setRole(Role.ADMIN);
user.setVerified(true);
return UserResponse.from(userRepository.save(user));
}
@PostMapping("/users/{id}/revoke-admin")
public UserResponse revokeAdmin(@PathVariable Long id, Authentication authentication) {
AppUser user = requireUser(id);
if (user.getRole() != Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Użytkownik nie ma roli administratora");
}
AppUser currentAdmin = requireAdmin(authentication);
if (currentAdmin.getId().equals(user.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz odebrać roli administratora samemu sobie");
}
if (userRepository.countByRole(Role.ADMIN) <= 1) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można odebrać roli ostatniemu administratorowi");
}
user.setRole(Role.USER);
return UserResponse.from(userRepository.save(user));
}
@PostMapping("/users/{id}/reset-password")
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
AppUser user = requireUser(id);
if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można resetować hasła konta administratora z tego widoku");
}
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
userRepository.save(user);
return new ResetPasswordResponse(temporaryPassword);
}
@DeleteMapping("/users/{id}") @DeleteMapping("/users/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) { public void deleteUser(@PathVariable Long id) {
@@ -205,4 +263,27 @@ public class AdminController {
public void deleteForbiddenWord(@PathVariable Long id) { public void deleteForbiddenWord(@PathVariable Long id) {
textModerationService.removeForbiddenWord(id); textModerationService.removeForbiddenWord(id);
} }
// --- Statystyki ---
@GetMapping("/stats")
public AdminStatsResponse stats(
@RequestParam(name = "days", defaultValue = "1") int days,
@RequestParam(name = "from", required = false) LocalDate from,
@RequestParam(name = "to", required = false) LocalDate to
) {
if (from != null || to != null) {
return adminStatsService.getStats(from, to);
}
return adminStatsService.getStats(days);
}
private String generateTemporaryPassword() {
StringBuilder password = new StringBuilder(TEMP_PASSWORD_LENGTH);
for (int index = 0; index < TEMP_PASSWORD_LENGTH; index++) {
int randomIndex = SECURE_RANDOM.nextInt(PASSWORD_ALPHABET.length());
password.append(PASSWORD_ALPHABET.charAt(randomIndex));
}
return password.toString();
}
} }
@@ -0,0 +1,45 @@
package pl.polskalokalnie.admin;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public record AdminStatsResponse(
int periodDays,
LocalDate fromDate,
LocalDate toDate,
Instant generatedAt,
Summary summary,
Period period,
List<DailyPoint> daily
) {
public record Summary(
long activeListings,
long pendingListings,
long totalUsers,
long pendingVerificationUsers,
long openReports,
long messagesToAdminTotal,
long blockedUsers
) {
}
public record Period(
long messagesToAdmin,
long newListings,
long newReports,
long newPersonalAccounts,
long newCompanyAccounts
) {
}
public record DailyPoint(
LocalDate date,
long messagesToAdmin,
long newListings,
long newReports,
long newPersonalAccounts,
long newCompanyAccounts
) {
}
}
@@ -0,0 +1,172 @@
package pl.polskalokalnie.admin;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.message.Message;
import pl.polskalokalnie.message.MessageRepository;
import pl.polskalokalnie.report.ListingReport;
import pl.polskalokalnie.report.ListingReportRepository;
import pl.polskalokalnie.report.ListingReportStatus;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@Service
public class AdminStatsService {
private static final int MIN_PERIOD_DAYS = 1;
private static final int MAX_PERIOD_DAYS = 60;
private static final ZoneId ZONE_ID = ZoneId.of("Europe/Warsaw");
private final ListingRepository listingRepository;
private final UserRepository userRepository;
private final ListingReportRepository listingReportRepository;
private final MessageRepository messageRepository;
public AdminStatsService(
ListingRepository listingRepository,
UserRepository userRepository,
ListingReportRepository listingReportRepository,
MessageRepository messageRepository
) {
this.listingRepository = listingRepository;
this.userRepository = userRepository;
this.listingReportRepository = listingReportRepository;
this.messageRepository = messageRepository;
}
public AdminStatsResponse getStats(int requestedPeriodDays) {
int periodDays = Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, requestedPeriodDays));
Instant now = Instant.now();
LocalDate today = LocalDate.now(ZONE_ID);
LocalDate fromDate = today.minusDays(periodDays - 1L);
return buildStats(fromDate, today, periodDays, now);
}
public AdminStatsResponse getStats(LocalDate requestedFromDate, LocalDate requestedToDate) {
LocalDate today = LocalDate.now(ZONE_ID);
LocalDate toDate = requestedToDate == null ? today : requestedToDate;
LocalDate fromDate = requestedFromDate == null ? toDate : requestedFromDate;
if (fromDate.isAfter(toDate)) {
LocalDate swapped = fromDate;
fromDate = toDate;
toDate = swapped;
}
if (toDate.isAfter(today)) {
toDate = today;
}
if (fromDate.isAfter(toDate)) {
fromDate = toDate;
}
long rawDays = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1L;
int periodDays = (int) Math.max(MIN_PERIOD_DAYS, Math.min(MAX_PERIOD_DAYS, rawDays));
if (rawDays > MAX_PERIOD_DAYS) {
fromDate = toDate.minusDays(MAX_PERIOD_DAYS - 1L);
}
Instant now = Instant.now();
return buildStats(fromDate, toDate, periodDays, now);
}
private AdminStatsResponse buildStats(LocalDate fromDate, LocalDate toDate, int periodDays, Instant generatedAt) {
Instant periodStart = fromDate.atStartOfDay(ZONE_ID).toInstant();
Instant periodEndExclusive = toDate.plusDays(1L).atStartOfDay(ZONE_ID).toInstant();
Instant periodEndInclusive = generatedAt.isBefore(periodEndExclusive) ? generatedAt : periodEndExclusive.minusNanos(1L);
List<PropertyListing> listings = listingRepository.findAll();
List<AppUser> users = userRepository.findAll();
List<ListingReport> reports = listingReportRepository.findAll();
List<Message> messages = messageRepository.findAll();
Long adminId = users.stream()
.filter(user -> user.getRole() == Role.ADMIN)
.map(AppUser::getId)
.findFirst()
.orElse(null);
List<AppUser> regularUsers = users.stream()
.filter(user -> user.getRole() != Role.ADMIN)
.toList();
List<Message> messagesToAdmin = adminId == null
? List.of()
: messages.stream()
.filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
.toList();
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
listings.stream().filter(item -> item.getStatus() == ListingStatus.APPROVED).count(),
listings.stream().filter(item -> item.getStatus() == ListingStatus.PENDING).count(),
regularUsers.size(),
regularUsers.stream().filter(item -> !item.isVerified()).count(),
reports.stream().filter(item -> item.getStatus() == ListingReportStatus.OPEN).count(),
messagesToAdmin.size(),
regularUsers.stream().filter(AppUser::isBlocked).count()
);
AdminStatsResponse.Period period = new AdminStatsResponse.Period(
messagesToAdmin.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
listings.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
reports.stream().filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive)).count(),
regularUsers.stream()
.filter(item -> item.getAccountType() == AccountType.PERSONAL)
.filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive))
.count(),
regularUsers.stream()
.filter(item -> item.getAccountType() == AccountType.COMPANY)
.filter(item -> inRange(item.getCreatedAt(), periodStart, periodEndInclusive))
.count()
);
List<AdminStatsResponse.DailyPoint> daily = new ArrayList<>();
for (LocalDate current = fromDate; !current.isAfter(toDate); current = current.plusDays(1)) {
LocalDate nextDate = current.plusDays(1);
Instant dayStart = current.atStartOfDay(ZONE_ID).toInstant();
Instant dayEnd = nextDate.atStartOfDay(ZONE_ID).toInstant();
daily.add(new AdminStatsResponse.DailyPoint(
current,
messagesToAdmin.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
listings.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
reports.stream().filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd)).count(),
regularUsers.stream()
.filter(item -> item.getAccountType() == AccountType.PERSONAL)
.filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd))
.count(),
regularUsers.stream()
.filter(item -> item.getAccountType() == AccountType.COMPANY)
.filter(item -> inRangeExclusiveEnd(item.getCreatedAt(), dayStart, dayEnd))
.count()
));
}
daily.sort(Comparator.comparing(AdminStatsResponse.DailyPoint::date));
return new AdminStatsResponse(periodDays, fromDate, toDate, generatedAt, summary, period, daily);
}
private static boolean inRange(Instant timestamp, Instant startInclusive, Instant endInclusive) {
return timestamp != null
&& !timestamp.isBefore(startInclusive)
&& !timestamp.isAfter(endInclusive);
}
private static boolean inRangeExclusiveEnd(Instant timestamp, Instant startInclusive, Instant endExclusive) {
return timestamp != null
&& !timestamp.isBefore(startInclusive)
&& timestamp.isBefore(endExclusive);
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.admin;
public record ResetPasswordResponse(
String temporaryPassword
) {
}
@@ -42,6 +42,7 @@ public record ListingCreateRequest(
Double lng, Double lng,
List<String> media, List<String> media,
List<String> amenities, List<String> amenities,
List<String> photos List<String> photos,
String virtualTourUrl
) { ) {
} }
@@ -44,6 +44,7 @@ public record ListingDetailResponse(
List<String> media, List<String> media,
List<String> amenities, List<String> amenities,
List<String> photos, List<String> photos,
String virtualTourUrl,
String ownerEmail, String ownerEmail,
ListingStatus status, ListingStatus status,
Instant createdAt, Instant createdAt,
@@ -86,6 +87,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getMedia()), List.copyOf(listing.getMedia()),
List.copyOf(listing.getAmenities()), List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()), List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(),
listing.getOwnerEmail(), listing.getOwnerEmail(),
listing.getStatus(), listing.getStatus(),
listing.getCreatedAt(), listing.getCreatedAt(),
@@ -3,6 +3,8 @@ package pl.polskalokalnie.listing;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.net.URI;
import java.net.URISyntaxException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -100,6 +102,7 @@ public class ListingService {
List<String> photos = limitPhotos(request.photos()); List<String> photos = limitPhotos(request.photos());
listing.setPhotos(photos); listing.setPhotos(photos);
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0)); listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
listing.setOwnerEmail(ownerEmail); listing.setOwnerEmail(ownerEmail);
listing.setStatus(ListingStatus.PENDING); listing.setStatus(ListingStatus.PENDING);
@@ -165,4 +168,29 @@ public class ListingService {
} }
return result; return result;
} }
private static String normalizeVirtualTourUrl(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
return null;
}
if (normalized.length() > 1200) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru jest zbyt długi");
}
try {
URI uri = new URI(normalized);
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru");
}
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link do wirtualnego spaceru musi zaczynać się od http:// lub https://");
}
return uri.toString();
} catch (URISyntaxException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj poprawny link URL do wirtualnego spaceru");
}
}
} }
@@ -142,6 +142,9 @@ public class PropertyListing {
@Column(columnDefinition = "text") @Column(columnDefinition = "text")
private String coverPhoto; private String coverPhoto;
@Column(length = 1200)
private String virtualTourUrl;
@Column(length = 180) @Column(length = 180)
private String ownerEmail; private String ownerEmail;
@@ -446,6 +449,14 @@ public class PropertyListing {
this.coverPhoto = coverPhoto; this.coverPhoto = coverPhoto;
} }
public String getVirtualTourUrl() {
return virtualTourUrl;
}
public void setVirtualTourUrl(String virtualTourUrl) {
this.virtualTourUrl = virtualTourUrl;
}
public String getOwnerEmail() { public String getOwnerEmail() {
return ownerEmail; return ownerEmail;
} }
@@ -10,4 +10,6 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
boolean existsByEmailIgnoreCase(String email); boolean existsByEmailIgnoreCase(String email);
Optional<AppUser> findFirstByRole(Role role); Optional<AppUser> findFirstByRole(Role role);
long countByRole(Role role);
} }
+2347 -62
View File
File diff suppressed because it is too large Load Diff
+1621 -1
View File
File diff suppressed because it is too large Load Diff