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.ListingReportService;
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.BlockedEmail;
import pl.polskalokalnie.user.BlockedEmailRepository;
@@ -49,6 +53,8 @@ public class AdminController {
private final BlockedEmailRepository blockedEmailRepository;
private final ListingService listingService;
private final ListingReportService listingReportService;
private final UserReportService userReportService;
private final SupportMessageService supportMessageService;
private final MessageService messageService;
private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService;
@@ -57,6 +63,8 @@ public class AdminController {
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService,
UserReportService userReportService,
SupportMessageService supportMessageService,
MessageService messageService,
TextModerationService textModerationService,
AdminStatsService adminStatsService,
@@ -66,6 +74,8 @@ public class AdminController {
this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService;
this.listingReportService = listingReportService;
this.userReportService = userReportService;
this.supportMessageService = supportMessageService;
this.messageService = messageService;
this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService;
@@ -263,6 +273,43 @@ public class AdminController {
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 ---
@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"
);
// 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.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
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());
}
// Endpoint jest publiczny - authentication bywa puste, a wtedy widac tylko ogloszenia opublikowane.
@GetMapping("/{id}")
public ListingDetailResponse getById(
@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
@@ -46,12 +46,15 @@ public record ListingDetailResponse(
List<String> photos,
String virtualTourUrl,
String ownerEmail,
// Konto wlasciciela, gdy ogloszenie nalezy do zarejestrowanego uzytkownika - po tym
// identyfikatorze frontend otwiera jego profil publiczny.
Long ownerId,
ListingStatus status,
Instant createdAt,
Long viewsCount,
Instant promotedUntil
) {
public static ListingDetailResponse from(PropertyListing listing) {
public static ListingDetailResponse from(PropertyListing listing, Long ownerId) {
return new ListingDetailResponse(
listing.getId(),
listing.getTitle(),
@@ -90,6 +93,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(),
listing.getOwnerEmail(),
ownerId,
listing.getStatus(),
listing.getCreatedAt(),
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;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
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 pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Service
public class ListingService {
@@ -20,12 +22,22 @@ public class ListingService {
private final ListingRepository listingRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
private final UserRepository userRepository;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService) {
NotificationService notificationService, UserRepository userRepository) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
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.
@@ -45,17 +57,28 @@ public class ListingService {
.toList();
}
/**
* Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane,
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.
*/
@Transactional
public ListingDetailResponse getById(Long id, boolean incrementView) {
public ListingDetailResponse getById(Long id, boolean incrementView, String viewerEmail, boolean viewerIsAdmin) {
PropertyListing listing = listingRepository.findById(id)
.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 = listingRepository.save(listing);
}
return ListingDetailResponse.from(listing);
return ListingDetailResponse.from(listing, ownerIdOf(listing));
}
public List<ListingResponse> findMine(String ownerEmail) {
@@ -110,7 +133,8 @@ public class ListingService {
listing.setOwnerEmail(ownerEmail);
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 -
@@ -128,7 +152,8 @@ public class ListingService {
applyRequest(listing, request);
// 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).
@@ -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 {
private final ListingReportService listingReportService;
private final UserReportService userReportService;
public ListingReportController(ListingReportService listingReportService) {
public ListingReportController(ListingReportService listingReportService, UserReportService userReportService) {
this.listingReportService = listingReportService;
this.userReportService = userReportService;
}
@PostMapping("/listings")
@@ -24,4 +26,13 @@ public class ListingReportController {
) {
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;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -15,11 +14,6 @@ import pl.polskalokalnie.user.UserRepository;
@Service
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 ListingRepository listingRepository;
private final UserRepository userRepository;
@@ -47,7 +41,7 @@ public class ListingReportService {
report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(trimToNull(request.details()));
report.setAttachments(cleanAttachments(request.attachmentNames(), request.attachmentFiles()));
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);
@@ -96,72 +90,6 @@ public class ListingReportService {
}
private static String trimToNull(String value) {
if (value == null) {
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);
return ReportAttachments.trimToNull(value);
}
}
@@ -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
) {
}
}