alery cenowe dokonczyc
This commit is contained in:
@@ -2,8 +2,10 @@ package pl.polskalokalnie.admin;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.security.SecureRandom;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.RequestBody;
|
||||
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.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDate;
|
||||
import pl.polskalokalnie.moderation.ForbiddenWordResponse;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
@@ -36,23 +40,33 @@ import pl.polskalokalnie.user.UserRepository;
|
||||
@RequestMapping("/api/admin")
|
||||
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 BlockedEmailRepository blockedEmailRepository;
|
||||
private final ListingService listingService;
|
||||
private final ListingReportService listingReportService;
|
||||
private final MessageService messageService;
|
||||
private final TextModerationService textModerationService;
|
||||
private final AdminStatsService adminStatsService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||
ListingService listingService, ListingReportService listingReportService,
|
||||
MessageService messageService,
|
||||
TextModerationService textModerationService) {
|
||||
TextModerationService textModerationService,
|
||||
AdminStatsService adminStatsService,
|
||||
PasswordEncoder passwordEncoder) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
this.listingService = listingService;
|
||||
this.listingReportService = listingReportService;
|
||||
this.messageService = messageService;
|
||||
this.textModerationService = textModerationService;
|
||||
this.adminStatsService = adminStatsService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
// --- Uzytkownicy ---
|
||||
@@ -82,6 +96,50 @@ public class AdminController {
|
||||
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}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteUser(@PathVariable Long id) {
|
||||
@@ -205,4 +263,27 @@ public class AdminController {
|
||||
public void deleteForbiddenWord(@PathVariable Long 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,
|
||||
List<String> media,
|
||||
List<String> amenities,
|
||||
List<String> photos
|
||||
List<String> photos,
|
||||
String virtualTourUrl
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public record ListingDetailResponse(
|
||||
List<String> media,
|
||||
List<String> amenities,
|
||||
List<String> photos,
|
||||
String virtualTourUrl,
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Instant createdAt,
|
||||
@@ -86,6 +87,7 @@ public record ListingDetailResponse(
|
||||
List.copyOf(listing.getMedia()),
|
||||
List.copyOf(listing.getAmenities()),
|
||||
List.copyOf(listing.getPhotos()),
|
||||
listing.getVirtualTourUrl(),
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
|
||||
@@ -3,6 +3,8 @@ package pl.polskalokalnie.listing;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -100,6 +102,7 @@ public class ListingService {
|
||||
List<String> photos = limitPhotos(request.photos());
|
||||
listing.setPhotos(photos);
|
||||
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
||||
listing.setVirtualTourUrl(normalizeVirtualTourUrl(request.virtualTourUrl()));
|
||||
|
||||
listing.setOwnerEmail(ownerEmail);
|
||||
listing.setStatus(ListingStatus.PENDING);
|
||||
@@ -165,4 +168,29 @@ public class ListingService {
|
||||
}
|
||||
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")
|
||||
private String coverPhoto;
|
||||
|
||||
@Column(length = 1200)
|
||||
private String virtualTourUrl;
|
||||
|
||||
@Column(length = 180)
|
||||
private String ownerEmail;
|
||||
|
||||
@@ -446,6 +449,14 @@ public class PropertyListing {
|
||||
this.coverPhoto = coverPhoto;
|
||||
}
|
||||
|
||||
public String getVirtualTourUrl() {
|
||||
return virtualTourUrl;
|
||||
}
|
||||
|
||||
public void setVirtualTourUrl(String virtualTourUrl) {
|
||||
this.virtualTourUrl = virtualTourUrl;
|
||||
}
|
||||
|
||||
public String getOwnerEmail() {
|
||||
return ownerEmail;
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
|
||||
Optional<AppUser> findFirstByRole(Role role);
|
||||
|
||||
long countByRole(Role role);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user