panel glowny poprawki

This commit is contained in:
2026-08-11 17:07:34 +02:00
parent b30a0b9b95
commit 1e361bde93
33 changed files with 3926 additions and 587 deletions
@@ -31,6 +31,10 @@ import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.report.ListingReportResponse; import pl.polskalokalnie.report.ListingReportResponse;
import pl.polskalokalnie.report.ListingReportService; import pl.polskalokalnie.report.ListingReportService;
import pl.polskalokalnie.report.ResolveListingReportRequest; import pl.polskalokalnie.report.ResolveListingReportRequest;
import pl.polskalokalnie.report.UserReportResponse;
import pl.polskalokalnie.report.UserReportService;
import pl.polskalokalnie.support.SupportMessageResponse;
import pl.polskalokalnie.support.SupportMessageService;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.BlockedEmail; import pl.polskalokalnie.user.BlockedEmail;
import pl.polskalokalnie.user.BlockedEmailRepository; import pl.polskalokalnie.user.BlockedEmailRepository;
@@ -49,6 +53,8 @@ public class AdminController {
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 UserReportService userReportService;
private final SupportMessageService supportMessageService;
private final MessageService messageService; private final MessageService messageService;
private final TextModerationService textModerationService; private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService; private final AdminStatsService adminStatsService;
@@ -57,6 +63,8 @@ public class AdminController {
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService, ListingService listingService, ListingReportService listingReportService,
UserReportService userReportService,
SupportMessageService supportMessageService,
MessageService messageService, MessageService messageService,
TextModerationService textModerationService, TextModerationService textModerationService,
AdminStatsService adminStatsService, AdminStatsService adminStatsService,
@@ -66,6 +74,8 @@ public class AdminController {
this.blockedEmailRepository = blockedEmailRepository; this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService; this.listingService = listingService;
this.listingReportService = listingReportService; this.listingReportService = listingReportService;
this.userReportService = userReportService;
this.supportMessageService = supportMessageService;
this.messageService = messageService; this.messageService = messageService;
this.textModerationService = textModerationService; this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService; this.adminStatsService = adminStatsService;
@@ -263,6 +273,43 @@ public class AdminController {
return listingReportService.deleteListingAndResolve(id, authentication.getName()); return listingReportService.deleteListingAndResolve(id, authentication.getName());
} }
// --- Zgloszenia uzytkownikow (z rozmow w wiadomosciach) ---
@GetMapping("/reports/users")
public List<UserReportResponse> userReports() {
return userReportService.findAllForAdmin();
}
@PostMapping("/reports/users/{id}/resolve")
public UserReportResponse resolveUserReport(
@PathVariable Long id,
Authentication authentication,
@RequestBody(required = false) ResolveListingReportRequest request
) {
return userReportService.resolve(id, authentication.getName(), request == null ? null : request.note());
}
@PostMapping("/reports/users/{id}/delete-user")
public UserReportResponse deleteUserFromReport(@PathVariable Long id, Authentication authentication) {
return userReportService.deleteReportedUserAndResolve(id, authentication.getName());
}
// --- Wiadomosci z formularza "Napisz do nas" (/konto/pomoc) ---
@GetMapping("/support-messages")
public List<SupportMessageResponse> supportMessages() {
return supportMessageService.findAllForAdmin();
}
@PostMapping("/support-messages/{id}/handled")
public SupportMessageResponse setSupportMessageHandled(
@PathVariable Long id,
@RequestParam(defaultValue = "true") boolean handled,
Authentication authentication
) {
return supportMessageService.setHandled(id, handled, authentication.getName());
}
// --- Slowa zabronione --- // --- Slowa zabronione ---
@GetMapping("/forbidden-words") @GetMapping("/forbidden-words")
@@ -48,6 +48,18 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT" "ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT"
); );
// Hibernate (ddl-auto=update) nie potrafi dodac kolumny NOT NULL do tabeli z danymi -
// dokladamy ja z wartoscia domyslna, zeby istniejace zgloszenia sie nie wywracaly.
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_reports ADD COLUMN IF NOT EXISTS reported_user_deleted BOOLEAN NOT NULL DEFAULT FALSE"
);
// Blokowanie konta ze zgloszenia zostalo usuniete - encja nie mapuje juz tej kolumny,
// wiec stare wiersze zostaja, a nowe wpisy wypelnia wartosc domyslna.
jdbcTemplate.execute(
"ALTER TABLE IF EXISTS user_reports ALTER COLUMN reported_user_blocked SET DEFAULT FALSE"
);
// Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci. // Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika). // Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
jdbcTemplate.execute( jdbcTemplate.execute(
@@ -0,0 +1,148 @@
package pl.polskalokalnie.listing;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Ranking miast i dzielnic liczony z aktualnie opublikowanych ogloszen.
*/
@RestController
@RequestMapping("/api/listings")
public class AreaRankingController {
private static final int TREND_WINDOW_DAYS = 90;
private static final int NEW_WINDOW_DAYS = 30;
private static final int MIN_LISTINGS_FOR_TREND = 2;
private final ListingRepository listingRepository;
private final ListingFavoriteRepository favoriteRepository;
public AreaRankingController(ListingRepository listingRepository, ListingFavoriteRepository favoriteRepository) {
this.listingRepository = listingRepository;
this.favoriteRepository = favoriteRepository;
}
@GetMapping("/areas")
public AreaRankingResponse areas(
@RequestParam(required = false) OfferType offerType,
@RequestParam(required = false) PropertyType propertyType
) {
List<PropertyListing> listings = listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0)
.filter(listing -> listing.getArea() != null && listing.getArea() > 0)
.filter(listing -> listing.getCity() != null && !listing.getCity().isBlank())
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.toList();
Map<Long, Long> savesByListing = savesFor(listings);
List<AreaRankingResponse.AreaStats> cities = group(
listings, listing -> listing.getCity().trim(), savesByListing, false);
List<AreaRankingResponse.AreaStats> districts = group(
listings.stream().filter(listing -> listing.getDistrict() != null && !listing.getDistrict().isBlank()).toList(),
listing -> listing.getCity().trim() + "|" + listing.getDistrict().trim(), savesByListing, true);
return new AreaRankingResponse(Instant.now(), listings.size(), cities, districts);
}
private Map<Long, Long> savesFor(List<PropertyListing> listings) {
List<Long> ids = listings.stream().map(PropertyListing::getId).toList();
if (ids.isEmpty()) {
return Map.of();
}
return favoriteRepository.findByListingIdIn(ids).stream()
.collect(Collectors.groupingBy(ListingFavorite::getListingId, Collectors.counting()));
}
private List<AreaRankingResponse.AreaStats> group(
List<PropertyListing> listings,
Function<PropertyListing, String> keyOf,
Map<Long, Long> savesByListing,
boolean withDistrict
) {
Map<String, List<PropertyListing>> grouped = listings.stream()
.collect(Collectors.groupingBy(keyOf, LinkedHashMap::new, Collectors.toList()));
List<AreaRankingResponse.AreaStats> stats = new ArrayList<>();
Instant newSince = Instant.now().minus(NEW_WINDOW_DAYS, ChronoUnit.DAYS);
Instant trendSince = Instant.now().minus(TREND_WINDOW_DAYS, ChronoUnit.DAYS);
grouped.forEach((key, items) -> {
String city = withDistrict ? key.substring(0, key.indexOf('|')) : key;
String district = withDistrict ? key.substring(key.indexOf('|') + 1) : null;
List<Double> pricesPerM2 = items.stream().map(AreaRankingController::pricePerM2).sorted().toList();
double avg = pricesPerM2.stream().mapToDouble(Double::doubleValue).average().orElse(0);
long views = items.stream().mapToLong(item -> item.getViewsCount() == null ? 0L : item.getViewsCount()).sum();
long saves = items.stream().mapToLong(item -> savesByListing.getOrDefault(item.getId(), 0L)).sum();
int newCount = (int) items.stream().filter(item -> item.getCreatedAt() != null && item.getCreatedAt().isAfter(newSince)).count();
stats.add(new AreaRankingResponse.AreaStats(
city,
district,
items.size(),
Math.round(avg),
Math.round(pricesPerM2.get(0)),
Math.round(pricesPerM2.get(pricesPerM2.size() - 1)),
Math.round(items.stream().mapToDouble(PropertyListing::getArea).average().orElse(0) * 10) / 10.0,
views,
saves,
newCount,
trendPercent(items, trendSince),
averageCoordinate(items, PropertyListing::getLat),
averageCoordinate(items, PropertyListing::getLng)
));
});
stats.sort(Comparator.comparingLong(AreaRankingResponse.AreaStats::avgPricePerM2).reversed());
return stats;
}
/** Zmiana sredniej ceny za m2 miedzy ofertami z ostatnich 90 dni a starszymi. */
private static Double trendPercent(List<PropertyListing> items, Instant trendSince) {
List<Double> recent = items.stream()
.filter(item -> item.getCreatedAt() != null && item.getCreatedAt().isAfter(trendSince))
.map(AreaRankingController::pricePerM2)
.toList();
List<Double> older = items.stream()
.filter(item -> item.getCreatedAt() != null && !item.getCreatedAt().isAfter(trendSince))
.map(AreaRankingController::pricePerM2)
.toList();
if (recent.size() < MIN_LISTINGS_FOR_TREND || older.size() < MIN_LISTINGS_FOR_TREND) {
return null;
}
double recentAvg = recent.stream().mapToDouble(Double::doubleValue).average().orElse(0);
double olderAvg = older.stream().mapToDouble(Double::doubleValue).average().orElse(0);
if (olderAvg <= 0) {
return null;
}
return Math.round(((recentAvg - olderAvg) / olderAvg) * 1000) / 10.0;
}
private static Double averageCoordinate(List<PropertyListing> items, Function<PropertyListing, Double> getter) {
List<Double> values = items.stream().map(getter).filter(java.util.Objects::nonNull).toList();
if (values.isEmpty()) {
return null;
}
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
}
private static double pricePerM2(PropertyListing listing) {
return listing.getPrice().doubleValue() / listing.getArea();
}
}
@@ -0,0 +1,35 @@
package pl.polskalokalnie.listing;
import java.time.Instant;
import java.util.List;
/**
* Ranking obszarow liczony na biezaco z opublikowanych ogloszen. Kazde dodane, zatwierdzone,
* wstrzymane lub usuniete ogloszenie zmienia wynik - nie ma tu zadnych danych wpisanych na sztywno.
*/
public record AreaRankingResponse(
Instant generatedAt,
int totalListings,
List<AreaStats> cities,
List<AreaStats> districts
) {
public record AreaStats(
String city,
// null dla wiersza opisujacego cale miasto
String district,
int listingsCount,
long avgPricePerM2,
long minPricePerM2,
long maxPricePerM2,
double avgArea,
long totalViews,
long totalSaves,
int newLast30Days,
// Zmiana sredniej ceny za m2: ostatnie 90 dni wobec starszych ofert.
// null, gdy po ktorejkolwiek stronie jest mniej niz 2 oferty - wtedy trend nic nie znaczy.
Double trendPercent,
Double lat,
Double lng
) {
}
}
@@ -40,12 +40,17 @@ public class ListingController {
return listingService.findMine(authentication.getName()); return listingService.findMine(authentication.getName());
} }
// Endpoint jest publiczny - authentication bywa puste, a wtedy widac tylko ogloszenia opublikowane.
@GetMapping("/{id}") @GetMapping("/{id}")
public ListingDetailResponse getById( public ListingDetailResponse getById(
@PathVariable Long id, @PathVariable Long id,
@RequestParam(defaultValue = "false") boolean incrementView @RequestParam(defaultValue = "false") boolean incrementView,
Authentication authentication
) { ) {
return listingService.getById(id, incrementView); String viewerEmail = authentication == null ? null : authentication.getName();
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
} }
@PostMapping @PostMapping
@@ -46,12 +46,15 @@ public record ListingDetailResponse(
List<String> photos, List<String> photos,
String virtualTourUrl, String virtualTourUrl,
String ownerEmail, String ownerEmail,
// Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym
// identyfikatorze frontend otwiera jego profil publiczny.
Long ownerId,
ListingStatus status, ListingStatus status,
Instant createdAt, Instant createdAt,
Long viewsCount, Long viewsCount,
Instant promotedUntil Instant promotedUntil
) { ) {
public static ListingDetailResponse from(PropertyListing listing) { public static ListingDetailResponse from(PropertyListing listing, Long ownerId) {
return new ListingDetailResponse( return new ListingDetailResponse(
listing.getId(), listing.getId(),
listing.getTitle(), listing.getTitle(),
@@ -90,6 +93,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getPhotos()), List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(), listing.getVirtualTourUrl(),
listing.getOwnerEmail(), listing.getOwnerEmail(),
ownerId,
listing.getStatus(), listing.getStatus(),
listing.getCreatedAt(), listing.getCreatedAt(),
listing.getViewsCount(), listing.getViewsCount(),
@@ -0,0 +1,67 @@
package pl.polskalokalnie.listing;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
/**
* Zapisanie ogloszenia do ulubionych przez uzytkownika. Trzymane po stronie serwera, zeby
* wlasciciel ogloszenia widzial realna liczbe zapisan, a nie tylko stan swojej przegladarki.
*/
@Entity
@Table(
name = "listing_favorites",
uniqueConstraints = @UniqueConstraint(name = "uk_listing_favorite", columnNames = {"listing_id", "user_email"})
)
public class ListingFavorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "listing_id", nullable = false)
private Long listingId;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,108 @@
package pl.polskalokalnie.listing;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
/**
* Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI),
* a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte.
*/
@RestController
@RequestMapping("/api/listings")
public class ListingFavoriteController {
private final ListingFavoriteRepository favoriteRepository;
private final ListingRepository listingRepository;
public ListingFavoriteController(ListingFavoriteRepository favoriteRepository, ListingRepository listingRepository) {
this.favoriteRepository = favoriteRepository;
this.listingRepository = listingRepository;
}
@PostMapping("/{id}/favorite")
@Transactional
public ResponseEntity<Void> addFavorite(@PathVariable Long id, Authentication authentication) {
if (!listingRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found");
}
save(id, authentication.getName());
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}/favorite")
@Transactional
public ResponseEntity<Void> removeFavorite(@PathVariable Long id, Authentication authentication) {
favoriteRepository.deleteByListingIdAndUserEmailIgnoreCase(id, authentication.getName());
return ResponseEntity.noContent().build();
}
/**
* Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie
* i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego,
* z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie.
*/
@PostMapping("/favorites/sync")
@Transactional
public ResponseEntity<Void> sync(@RequestBody SyncRequest request, Authentication authentication) {
if (request == null || request.listingIds() == null) {
return ResponseEntity.noContent().build();
}
String userEmail = authentication.getName();
List<Long> wanted = request.listingIds().stream()
.filter(java.util.Objects::nonNull)
.distinct()
.filter(listingRepository::existsById)
.toList();
favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream()
.filter(favorite -> !wanted.contains(favorite.getListingId()))
.forEach(favoriteRepository::delete);
wanted.forEach(listingId -> save(listingId, userEmail));
return ResponseEntity.noContent().build();
}
/** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */
@GetMapping("/mine/saves")
public Map<Long, Long> mySaves(Authentication authentication) {
List<Long> myListingIds = listingRepository.findAll().stream()
.filter(listing -> authentication.getName().equalsIgnoreCase(listing.getOwnerEmail()))
.map(PropertyListing::getId)
.toList();
Map<Long, Long> counts = new HashMap<>();
myListingIds.forEach(listingId -> counts.put(listingId, 0L));
if (myListingIds.isEmpty()) {
return counts;
}
favoriteRepository.findByListingIdIn(myListingIds)
.forEach(favorite -> counts.merge(favorite.getListingId(), 1L, Long::sum));
return counts;
}
private void save(Long listingId, String userEmail) {
if (favoriteRepository.existsByListingIdAndUserEmailIgnoreCase(listingId, userEmail)) {
return;
}
ListingFavorite favorite = new ListingFavorite();
favorite.setListingId(listingId);
favorite.setUserEmail(userEmail);
favoriteRepository.save(favorite);
}
public record SyncRequest(List<Long> listingIds) {
}
}
@@ -0,0 +1,17 @@
package pl.polskalokalnie.listing;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingFavoriteRepository extends JpaRepository<ListingFavorite, Long> {
boolean existsByListingIdAndUserEmailIgnoreCase(Long listingId, String userEmail);
void deleteByListingIdAndUserEmailIgnoreCase(Long listingId, String userEmail);
long countByListingId(Long listingId);
List<ListingFavorite> findByUserEmailIgnoreCase(String userEmail);
List<ListingFavorite> findByListingIdIn(List<Long> listingIds);
}
@@ -1,6 +1,9 @@
package pl.polskalokalnie.listing; package pl.polskalokalnie.listing;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingRepository extends JpaRepository<PropertyListing, Long> { public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
} }
@@ -11,6 +11,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Service @Service
public class ListingService { public class ListingService {
@@ -20,12 +22,22 @@ public class ListingService {
private final ListingRepository listingRepository; private final ListingRepository listingRepository;
private final TextModerationService textModerationService; private final TextModerationService textModerationService;
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserRepository userRepository;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService) { NotificationService notificationService, UserRepository userRepository) {
this.listingRepository = listingRepository; this.listingRepository = listingRepository;
this.textModerationService = textModerationService; this.textModerationService = textModerationService;
this.notificationService = notificationService; this.notificationService = notificationService;
this.userRepository = userRepository;
}
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
private Long ownerIdOf(PropertyListing listing) {
if (listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return null;
}
return userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).map(AppUser::getId).orElse(null);
} }
// Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania. // Promowane (aktywne wyroznienie) na gorze, potem najnowsze wg daty dodania.
@@ -45,17 +57,28 @@ public class ListingService {
.toList(); .toList();
} }
/**
* Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane,
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.
*/
@Transactional @Transactional
public ListingDetailResponse getById(Long id, boolean incrementView) { public ListingDetailResponse getById(Long id, boolean incrementView, String viewerEmail, boolean viewerIsAdmin) {
PropertyListing listing = listingRepository.findById(id) PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
if (incrementView) { boolean isOwner = viewerEmail != null && listing.getOwnerEmail() != null
&& viewerEmail.equalsIgnoreCase(listing.getOwnerEmail());
if (listing.getStatus() != ListingStatus.APPROVED && !isOwner && !viewerIsAdmin) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found");
}
// Podglad wlasnego ogloszenia nie zawyza licznika wyswietlen.
if (incrementView && !isOwner) {
listing.setViewsCount((listing.getViewsCount() == null ? 0L : listing.getViewsCount()) + 1L); listing.setViewsCount((listing.getViewsCount() == null ? 0L : listing.getViewsCount()) + 1L);
listing = listingRepository.save(listing); listing = listingRepository.save(listing);
} }
return ListingDetailResponse.from(listing); return ListingDetailResponse.from(listing, ownerIdOf(listing));
} }
public List<ListingResponse> findMine(String ownerEmail) { public List<ListingResponse> findMine(String ownerEmail) {
@@ -110,7 +133,8 @@ public class ListingService {
listing.setOwnerEmail(ownerEmail); listing.setOwnerEmail(ownerEmail);
listing.setStatus(ListingStatus.PENDING); listing.setStatus(ListingStatus.PENDING);
return ListingDetailResponse.from(listingRepository.save(listing)); PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved));
} }
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu - // Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
@@ -128,7 +152,8 @@ public class ListingService {
applyRequest(listing, request); applyRequest(listing, request);
// ownerEmail i status pozostaja bez zmian. // ownerEmail i status pozostaja bez zmian.
return ListingDetailResponse.from(listingRepository.save(listing)); PropertyListing saved = listingRepository.save(listing);
return ListingDetailResponse.from(saved, ownerIdOf(saved));
} }
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn). // Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
@@ -0,0 +1,22 @@
package pl.polskalokalnie.report;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
public record CreateUserReportRequest(
@NotBlank String reportedName,
// Wypelniony tylko dla rozmow z istniejacym kontem - wtedy admin moze je zablokowac.
String reportedEmail,
String conversationId,
// Ogloszenie, w sprawie ktorego toczyla sie rozmowa (jesli watek jest z nim powiazany).
Long listingId,
String listingTitle,
@NotBlank String reasonId,
@NotBlank String reasonTitle,
String details,
List<String> attachmentNames,
List<ListingReportAttachmentPayload> attachmentFiles,
// Migawka rozmowy dolaczona do zgloszenia.
List<UserReportMessagePayload> conversation
) {
}
@@ -12,9 +12,11 @@ import org.springframework.web.bind.annotation.RestController;
public class ListingReportController { public class ListingReportController {
private final ListingReportService listingReportService; private final ListingReportService listingReportService;
private final UserReportService userReportService;
public ListingReportController(ListingReportService listingReportService) { public ListingReportController(ListingReportService listingReportService, UserReportService userReportService) {
this.listingReportService = listingReportService; this.listingReportService = listingReportService;
this.userReportService = userReportService;
} }
@PostMapping("/listings") @PostMapping("/listings")
@@ -24,4 +26,13 @@ public class ListingReportController {
) { ) {
return listingReportService.create(request, authentication.getName()); return listingReportService.create(request, authentication.getName());
} }
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
@PostMapping("/users")
public UserReportResponse createUserReport(
@Valid @RequestBody CreateUserReportRequest request,
Authentication authentication
) {
return userReportService.create(request, authentication.getName());
}
} }
@@ -1,7 +1,6 @@
package pl.polskalokalnie.report; package pl.polskalokalnie.report;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,11 +14,6 @@ import pl.polskalokalnie.user.UserRepository;
@Service @Service
public class ListingReportService { public class ListingReportService {
private static final int MAX_ATTACHMENTS = 5;
private static final int MAX_FILE_NAME_LENGTH = 255;
private static final int MAX_FILE_TYPE_LENGTH = 120;
private static final int MAX_DATA_URL_LENGTH = 16_000_000;
private final ListingReportRepository listingReportRepository; private final ListingReportRepository listingReportRepository;
private final ListingRepository listingRepository; private final ListingRepository listingRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
@@ -47,7 +41,7 @@ public class ListingReportService {
report.setReasonId(request.reasonId().trim()); report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim()); report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(trimToNull(request.details())); report.setDetails(trimToNull(request.details()));
report.setAttachments(cleanAttachments(request.attachmentNames(), request.attachmentFiles())); report.setAttachments(ReportAttachments.clean(request.attachmentNames(), request.attachmentFiles()));
report.setReporterEmail(reporterEmail); report.setReporterEmail(reporterEmail);
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
report.setStatus(ListingReportStatus.OPEN); report.setStatus(ListingReportStatus.OPEN);
@@ -96,72 +90,6 @@ public class ListingReportService {
} }
private static String trimToNull(String value) { private static String trimToNull(String value) {
if (value == null) { return ReportAttachments.trimToNull(value);
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static List<ListingReportAttachment> cleanAttachments(
List<String> names,
List<ListingReportAttachmentPayload> files
) {
List<ListingReportAttachment> cleaned = new ArrayList<>();
if (files != null) {
for (ListingReportAttachmentPayload payload : files) {
if (payload == null) {
continue;
}
String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH);
if (fileName == null) {
continue;
}
ListingReportAttachment attachment = new ListingReportAttachment();
attachment.setFileName(fileName);
attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH));
String dataUrl = trimToNull(payload.dataUrl());
if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) {
dataUrl = null;
}
attachment.setDataUrl(dataUrl);
cleaned.add(attachment);
if (cleaned.size() >= MAX_ATTACHMENTS) {
return cleaned;
}
}
}
if (names != null) {
for (String name : names) {
String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH);
if (trimmed == null) {
continue;
}
boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName()));
if (exists) {
continue;
}
ListingReportAttachment attachment = new ListingReportAttachment();
attachment.setFileName(trimmed);
cleaned.add(attachment);
if (cleaned.size() >= MAX_ATTACHMENTS) {
break;
}
}
}
return cleaned;
}
private static String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
if (value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
} }
} }
@@ -0,0 +1,87 @@
package pl.polskalokalnie.report;
import java.util.ArrayList;
import java.util.List;
/**
* Czyszczenie zalacznikow zgloszenia - wspolne dla zgloszen ogloszen i uzytkownikow.
* Nazwy plikow przycinamy do dlugosci kolumny, zbyt duzy podglad (dataUrl) odrzucamy,
* a liczbe zalacznikow ograniczamy do MAX_ATTACHMENTS.
*/
final class ReportAttachments {
private static final int MAX_ATTACHMENTS = 5;
private static final int MAX_FILE_NAME_LENGTH = 255;
private static final int MAX_FILE_TYPE_LENGTH = 120;
private static final int MAX_DATA_URL_LENGTH = 16_000_000;
private ReportAttachments() {
}
static List<ListingReportAttachment> clean(List<String> names, List<ListingReportAttachmentPayload> files) {
List<ListingReportAttachment> cleaned = new ArrayList<>();
if (files != null) {
for (ListingReportAttachmentPayload payload : files) {
if (payload == null) {
continue;
}
String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH);
if (fileName == null) {
continue;
}
ListingReportAttachment attachment = new ListingReportAttachment();
attachment.setFileName(fileName);
attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH));
String dataUrl = trimToNull(payload.dataUrl());
if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) {
dataUrl = null;
}
attachment.setDataUrl(dataUrl);
cleaned.add(attachment);
if (cleaned.size() >= MAX_ATTACHMENTS) {
return cleaned;
}
}
}
if (names != null) {
for (String name : names) {
String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH);
if (trimmed == null) {
continue;
}
boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName()));
if (exists) {
continue;
}
ListingReportAttachment attachment = new ListingReportAttachment();
attachment.setFileName(trimmed);
cleaned.add(attachment);
if (cleaned.size() >= MAX_ATTACHMENTS) {
break;
}
}
}
return cleaned;
}
static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String truncate(String value, int maxLength) {
if (value == null) {
return null;
}
if (value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
}
@@ -0,0 +1,243 @@
package pl.polskalokalnie.report;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* Zgloszenie uzytkownika wyslane z rozmowy w wiadomosciach. Zgloszony jest opisany nazwa z watku;
* e-mail wypelniamy tylko wtedy, gdy rozmowa dotyczy istniejacego konta - dopiero wtedy admin moze
* zablokowac konto prosto ze zgloszenia.
*/
@Entity
@Table(name = "user_reports")
public class UserReport {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 160)
private String reportedName;
@Column(length = 180)
private String reportedEmail;
// Identyfikator rozmowy, z ktorej wyszlo zgloszenie - kontekst dla obslugi.
@Column(length = 120)
private String conversationId;
// Ogloszenie, w sprawie ktorego toczyla sie rozmowa (jesli watek jest z nim powiazany).
private Long listingId;
@Column(length = 180)
private String listingTitle;
// Migawka rozmowy z chwili zgloszenia - dowod dla obslugi.
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_report_messages", joinColumns = @JoinColumn(name = "report_id"))
@OrderColumn(name = "position")
private List<UserReportMessage> conversation = new ArrayList<>();
@Column(nullable = false, length = 80)
private String reasonId;
@Column(nullable = false, length = 180)
private String reasonTitle;
@Column(length = 1000)
private String details;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_report_attachments", joinColumns = @JoinColumn(name = "report_id"))
private List<ListingReportAttachment> attachments = new ArrayList<>();
@Column(nullable = false, length = 180)
private String reporterEmail;
@Column(length = 120)
private String reporterName;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ListingReportStatus status = ListingReportStatus.OPEN;
@Column(nullable = false)
private boolean reportedUserDeleted = false;
@Column(nullable = false, updatable = false)
private Instant createdAt;
private Instant resolvedAt;
@Column(length = 180)
private String resolvedByEmail;
@Column(length = 1000)
private String resolutionNote;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getReportedName() {
return reportedName;
}
public void setReportedName(String reportedName) {
this.reportedName = reportedName;
}
public String getReportedEmail() {
return reportedEmail;
}
public void setReportedEmail(String reportedEmail) {
this.reportedEmail = reportedEmail;
}
public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getListingTitle() {
return listingTitle;
}
public void setListingTitle(String listingTitle) {
this.listingTitle = listingTitle;
}
public List<UserReportMessage> getConversation() {
return conversation;
}
public void setConversation(List<UserReportMessage> conversation) {
this.conversation = conversation == null ? new ArrayList<>() : new ArrayList<>(conversation);
}
public boolean isReportedUserDeleted() {
return reportedUserDeleted;
}
public void setReportedUserDeleted(boolean reportedUserDeleted) {
this.reportedUserDeleted = reportedUserDeleted;
}
public String getReasonId() {
return reasonId;
}
public void setReasonId(String reasonId) {
this.reasonId = reasonId;
}
public String getReasonTitle() {
return reasonTitle;
}
public void setReasonTitle(String reasonTitle) {
this.reasonTitle = reasonTitle;
}
public String getDetails() {
return details;
}
public void setDetails(String details) {
this.details = details;
}
public List<ListingReportAttachment> getAttachments() {
return attachments;
}
public void setAttachments(List<ListingReportAttachment> attachments) {
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
}
public String getReporterEmail() {
return reporterEmail;
}
public void setReporterEmail(String reporterEmail) {
this.reporterEmail = reporterEmail;
}
public String getReporterName() {
return reporterName;
}
public void setReporterName(String reporterName) {
this.reporterName = reporterName;
}
public ListingReportStatus getStatus() {
return status;
}
public void setStatus(ListingReportStatus status) {
this.status = status;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getResolvedAt() {
return resolvedAt;
}
public void setResolvedAt(Instant resolvedAt) {
this.resolvedAt = resolvedAt;
}
public String getResolvedByEmail() {
return resolvedByEmail;
}
public void setResolvedByEmail(String resolvedByEmail) {
this.resolvedByEmail = resolvedByEmail;
}
public String getResolutionNote() {
return resolutionNote;
}
public void setResolutionNote(String resolutionNote) {
this.resolutionNote = resolutionNote;
}
}
@@ -0,0 +1,58 @@
package pl.polskalokalnie.report;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import java.time.Instant;
/**
* Pojedyncza wiadomosc z rozmowy dolaczona do zgloszenia uzytkownika. To migawka z chwili
* zgloszenia - obsluga widzi dokladnie te tresc, ktora zgloszil uzytkownik, nawet gdy rozmowa
* zostanie pozniej usunieta przez ktoras ze stron.
*/
@Embeddable
public class UserReportMessage {
@Column(name = "from_reporter", nullable = false)
private boolean fromReporter;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "sent_at")
private Instant sentAt;
@Column(name = "has_photo", nullable = false)
private boolean hasPhoto;
public boolean isFromReporter() {
return fromReporter;
}
public void setFromReporter(boolean fromReporter) {
this.fromReporter = fromReporter;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Instant getSentAt() {
return sentAt;
}
public void setSentAt(Instant sentAt) {
this.sentAt = sentAt;
}
public boolean isHasPhoto() {
return hasPhoto;
}
public void setHasPhoto(boolean hasPhoto) {
this.hasPhoto = hasPhoto;
}
}
@@ -0,0 +1,11 @@
package pl.polskalokalnie.report;
import java.time.Instant;
public record UserReportMessagePayload(
boolean fromReporter,
String content,
Instant sentAt,
boolean hasPhoto
) {
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.report;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserReportRepository extends JpaRepository<UserReport, Long> {
List<UserReport> findAllByOrderByCreatedAtDesc();
}
@@ -0,0 +1,61 @@
package pl.polskalokalnie.report;
import java.time.Instant;
import java.util.List;
public record UserReportResponse(
Long id,
String reportedName,
String reportedEmail,
String conversationId,
Long listingId,
String listingTitle,
String reasonId,
String reasonTitle,
String details,
List<String> attachmentNames,
List<ListingReportAttachmentResponse> attachments,
List<UserReportMessagePayload> conversation,
String reporterEmail,
String reporterName,
ListingReportStatus status,
boolean reportedUserDeleted,
// Czy zgloszony ma konto w serwisie - od tego zalezy, czy admin moze je zablokowac lub usunac.
boolean reportedUserExists,
Instant createdAt,
Instant resolvedAt,
String resolvedByEmail,
String resolutionNote
) {
public static UserReportResponse from(UserReport report, boolean reportedUserExists) {
return new UserReportResponse(
report.getId(),
report.getReportedName(),
report.getReportedEmail(),
report.getConversationId(),
report.getListingId(),
report.getListingTitle(),
report.getReasonId(),
report.getReasonTitle(),
report.getDetails(),
report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(),
report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(),
report.getConversation().stream()
.map(message -> new UserReportMessagePayload(
message.isFromReporter(),
message.getContent(),
message.getSentAt(),
message.isHasPhoto()))
.toList(),
report.getReporterEmail(),
report.getReporterName(),
report.getStatus(),
report.isReportedUserDeleted(),
reportedUserExists,
report.getCreatedAt(),
report.getResolvedAt(),
report.getResolvedByEmail(),
report.getResolutionNote()
);
}
}
@@ -0,0 +1,137 @@
package pl.polskalokalnie.report;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@Service
public class UserReportService {
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
private static final int MAX_CONVERSATION_MESSAGES = 50;
private static final int MAX_MESSAGE_LENGTH = 2000;
private final UserReportRepository userReportRepository;
private final UserRepository userRepository;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository) {
this.userReportRepository = userReportRepository;
this.userRepository = userRepository;
}
@Transactional
public UserReportResponse create(CreateUserReportRequest request, String reporterEmail) {
UserReport report = new UserReport();
report.setReportedName(request.reportedName().trim());
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
report.setListingId(request.listingId());
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
report.setConversation(cleanConversation(request.conversation()));
report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(ReportAttachments.trimToNull(request.details()));
report.setAttachments(ReportAttachments.clean(request.attachmentNames(), request.attachmentFiles()));
report.setReporterEmail(reporterEmail);
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
report.setStatus(ListingReportStatus.OPEN);
// Konto zgloszonego wiazemy tylko wtedy, gdy naprawde istnieje - rozmowy demonstracyjne
// nie maja odpowiednika w bazie i zostaja opisane sama nazwa z watku.
AppUser reported = findReported(request.reportedEmail());
if (reported != null) {
report.setReportedEmail(reported.getEmail());
report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName());
}
UserReport saved = userReportRepository.save(report);
return UserReportResponse.from(saved, reported != null);
}
public List<UserReportResponse> findAllForAdmin() {
return userReportRepository.findAllByOrderByCreatedAtDesc().stream()
.map(report -> UserReportResponse.from(report, findReported(report.getReportedEmail()) != null))
.toList();
}
@Transactional
public UserReportResponse resolve(Long reportId, String adminEmail, String note) {
UserReport report = requireReport(reportId);
report.setStatus(ListingReportStatus.RESOLVED);
report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail);
report.setResolutionNote(ReportAttachments.trimToNull(note));
return UserReportResponse.from(userReportRepository.save(report), findReported(report.getReportedEmail()) != null);
}
/** Usuniecie konta zgloszonego uzytkownika wraz z zamknieciem zgloszenia. */
@Transactional
public UserReportResponse deleteReportedUserAndResolve(Long reportId, String adminEmail) {
UserReport report = requireReport(reportId);
AppUser reported = findReported(report.getReportedEmail());
if (reported == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Zgłoszony użytkownik nie ma konta w serwisie - nie ma czego usunąć");
}
if (reported.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
userRepository.delete(reported);
report.setReportedUserDeleted(true);
report.setStatus(ListingReportStatus.RESOLVED);
report.setResolvedAt(Instant.now());
report.setResolvedByEmail(adminEmail);
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
report.setResolutionNote("Konto usunięte przez administratora po zgłoszeniu.");
}
return UserReportResponse.from(userReportRepository.save(report), false);
}
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
if (payloads == null || payloads.isEmpty()) {
return List.of();
}
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
: payloads;
List<UserReportMessage> cleaned = new ArrayList<>();
for (UserReportMessagePayload payload : tail) {
if (payload == null) {
continue;
}
String content = payload.content() == null ? "" : payload.content().trim();
if (content.length() > MAX_MESSAGE_LENGTH) {
content = content.substring(0, MAX_MESSAGE_LENGTH);
}
UserReportMessage message = new UserReportMessage();
message.setFromReporter(payload.fromReporter());
message.setContent(content);
message.setSentAt(payload.sentAt());
message.setHasPhoto(payload.hasPhoto());
cleaned.add(message);
}
return cleaned;
}
private AppUser findReported(String email) {
if (email == null || email.isBlank()) {
return null;
}
return userRepository.findByEmailIgnoreCase(email).orElse(null);
}
private UserReport requireReport(Long id) {
return userReportRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Report not found"));
}
}
@@ -0,0 +1,13 @@
package pl.polskalokalnie.support;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateSupportMessageRequest(
@NotBlank @Size(max = 160) String fullName,
@NotBlank @Email @Size(max = 180) String email,
@NotBlank @Size(max = 120) String topic,
@NotBlank @Size(max = 4000) String content
) {
}
@@ -0,0 +1,130 @@
package pl.polskalokalnie.support;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Wiadomosc z formularza "Napisz do nas" (/konto/pomoc). Osobny kanal od czatu z administracja -
* obsluga widzi tu zgloszenia z tematem i danymi kontaktowymi podanymi w formularzu.
*/
@Entity
@Table(name = "support_messages")
public class SupportMessage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Dane wpisane w formularzu - moga sie roznic od danych konta.
@Column(nullable = false, length = 160)
private String fullName;
@Column(nullable = false, length = 180)
private String email;
@Column(nullable = false, length = 120)
private String topic;
@Column(nullable = false, length = 4000)
private String content;
// Konto, z ktorego wyslano formularz.
@Column(nullable = false, length = 180)
private String accountEmail;
@Column(nullable = false)
private boolean handled = false;
private Instant handledAt;
@Column(length = 180)
private String handledByEmail;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getAccountEmail() {
return accountEmail;
}
public void setAccountEmail(String accountEmail) {
this.accountEmail = accountEmail;
}
public boolean isHandled() {
return handled;
}
public void setHandled(boolean handled) {
this.handled = handled;
}
public Instant getHandledAt() {
return handledAt;
}
public void setHandledAt(Instant handledAt) {
this.handledAt = handledAt;
}
public String getHandledByEmail() {
return handledByEmail;
}
public void setHandledByEmail(String handledByEmail) {
this.handledByEmail = handledByEmail;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,28 @@
package pl.polskalokalnie.support;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** Formularz "Napisz do nas" z /konto/pomoc. */
@RestController
@RequestMapping("/api/support")
public class SupportMessageController {
private final SupportMessageService service;
public SupportMessageController(SupportMessageService service) {
this.service = service;
}
@PostMapping("/messages")
@ResponseStatus(HttpStatus.CREATED)
public SupportMessageResponse create(@Valid @RequestBody CreateSupportMessageRequest request, Authentication authentication) {
return service.create(request, authentication.getName());
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.support;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SupportMessageRepository extends JpaRepository<SupportMessage, Long> {
List<SupportMessage> findAllByOrderByCreatedAtDesc();
}
@@ -0,0 +1,31 @@
package pl.polskalokalnie.support;
import java.time.Instant;
public record SupportMessageResponse(
Long id,
String fullName,
String email,
String topic,
String content,
String accountEmail,
boolean handled,
Instant handledAt,
String handledByEmail,
Instant createdAt
) {
public static SupportMessageResponse from(SupportMessage message) {
return new SupportMessageResponse(
message.getId(),
message.getFullName(),
message.getEmail(),
message.getTopic(),
message.getContent(),
message.getAccountEmail(),
message.isHandled(),
message.getHandledAt(),
message.getHandledByEmail(),
message.getCreatedAt()
);
}
}
@@ -0,0 +1,49 @@
package pl.polskalokalnie.support;
import java.time.Instant;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
@Service
public class SupportMessageService {
private final SupportMessageRepository repository;
private final TextModerationService textModerationService;
public SupportMessageService(SupportMessageRepository repository, TextModerationService textModerationService) {
this.repository = repository;
this.textModerationService = textModerationService;
}
@Transactional
public SupportMessageResponse create(CreateSupportMessageRequest request, String accountEmail) {
textModerationService.validateOrThrow(request.fullName(), request.content());
SupportMessage message = new SupportMessage();
message.setFullName(request.fullName().trim());
message.setEmail(request.email().trim());
message.setTopic(request.topic().trim());
message.setContent(request.content().trim());
message.setAccountEmail(accountEmail);
return SupportMessageResponse.from(repository.save(message));
}
public List<SupportMessageResponse> findAllForAdmin() {
return repository.findAllByOrderByCreatedAtDesc().stream().map(SupportMessageResponse::from).toList();
}
/** Oznaczenie wiadomosci jako obsluzonej (lub cofniecie tego oznaczenia). */
@Transactional
public SupportMessageResponse setHandled(Long id, boolean handled, String adminEmail) {
SupportMessage message = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Wiadomość nie istnieje"));
message.setHandled(handled);
message.setHandledAt(handled ? Instant.now() : null);
message.setHandledByEmail(handled ? adminEmail : null);
return SupportMessageResponse.from(repository.save(message));
}
}
@@ -0,0 +1,63 @@
package pl.polskalokalnie.user;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
/**
* Profil uzytkownika widziany przez innych zalogowanych - otwierany m.in. z rozmowy w wiadomosciach.
* Zwraca tylko dane jawne: nazwe, typ konta, date dolaczenia i opublikowane ogloszenia.
*/
@RestController
@RequestMapping("/api/users")
public class PublicProfileController {
private final UserRepository userRepository;
private final ListingRepository listingRepository;
public PublicProfileController(UserRepository userRepository, ListingRepository listingRepository) {
this.userRepository = userRepository;
this.listingRepository = listingRepository;
}
@GetMapping("/{id}/profile")
public PublicProfileResponse profile(@PathVariable Long id) {
AppUser user = userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono takiego użytkownika"));
List<PropertyListing> listings = user.getEmail() == null
? List.of()
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
return new PublicProfileResponse(
user.getId(),
user.getFullName(),
user.getAccountType(),
user.isVerified(),
user.getCreatedAt(),
listings.size(),
listings.stream()
.map(listing -> new PublicProfileResponse.PublicProfileListing(
listing.getId(),
listing.getTitle(),
listing.getCity(),
listing.getDistrict(),
listing.getPrice(),
listing.getOfferType().name(),
listing.getArea(),
listing.getRooms(),
listing.getFloor(),
listing.getBuildingFloors(),
listing.getCoverPhoto(),
listing.getCreatedAt()))
.toList()
);
}
}
@@ -0,0 +1,35 @@
package pl.polskalokalnie.user;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/**
* Publiczny profil uzytkownika ogladany przez innych (np. z rozmowy w wiadomosciach).
* Swiadomie nie zawiera danych kontaktowych - e-mail, telefon i adres zostaja po stronie serwera.
*/
public record PublicProfileResponse(
Long id,
String fullName,
AccountType accountType,
boolean verified,
Instant memberSince,
int listingsCount,
List<PublicProfileListing> listings
) {
public record PublicProfileListing(
Long id,
String title,
String city,
String district,
BigDecimal price,
String offerType,
Double area,
Integer rooms,
String floor,
String buildingFloors,
String coverPhoto,
Instant createdAt
) {
}
}
+1451 -391
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -38,6 +38,7 @@ export const ROUTES = {
messages: '/wiadomosci', messages: '/wiadomosci',
admin: '/admin', admin: '/admin',
listingDetail: '/oferta/:id', listingDetail: '/oferta/:id',
publicProfile: '/profil/:id',
} as const; } as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES]; export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
@@ -47,6 +48,11 @@ export function listingPath(id: number): string {
return `/oferta/${id}`; return `/oferta/${id}`;
} }
// Profil publiczny innego użytkownika - otwierany m.in. z rozmowy w wiadomościach.
export function publicProfilePath(id: number): string {
return `/profil/${id}`;
}
// Dedykowana strona edycji wlasnego ogloszenia. // Dedykowana strona edycji wlasnego ogloszenia.
export function listingEditPath(id: number): string { export function listingEditPath(id: number): string {
return `/edytuj-ogloszenie/${id}`; return `/edytuj-ogloszenie/${id}`;
@@ -99,6 +105,7 @@ export const ADMIN_TAB_PATHS = {
listings: 'ogloszenia', listings: 'ogloszenia',
users: 'uzytkownicy', users: 'uzytkownicy',
messages: 'wiadomosci', messages: 'wiadomosci',
support: 'pomoc-i-kontakt',
reports: 'zgloszenia', reports: 'zgloszenia',
payments: 'platnosci', payments: 'platnosci',
stats: 'statystyki', stats: 'statystyki',
+951 -87
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -14,6 +14,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Pliki z hashem w nazwie (assets/index-XXXX.js) sa niezmienne - mozna je trzymac dlugo.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# index.html nigdy nie moze byc cache'owany - inaczej przegladarka po wdrozeniu
# nadal laduje stary bundle i uzytkownik pracuje na nieaktualnej wersji aplikacji.
location = /index.html {
add_header Cache-Control "no-store, must-revalidate";
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }