Dodaj prawdziwe wiadomosci miedzy uzytkownikami o ogloszeniach
Deploy Polska Lokalnie / deploy (push) Successful in 12s

Dotad rozmowy z kupujacymi istnialy tylko w przegladarce nadawcy: nic nie
trafialo na serwer, "odczytano" i odpowiedz sprzedajacego byly symulowane
timerem. Wzorem Gieldy Komunalnej:

Backend (/api/conversations):
- Conversation: jeden watek na pare (ogloszenie, kupujacy), sprzedajacy to
  wlasciciel ogloszenia; tytul ogloszenia zapisany na wypadek jego usuniecia.
  Wiadomosci w istniejacej tabeli messages z nowa kolumna conversation_id,
  wiec rozmowa z administracja (conversation_id = null) dziala bez zmian.
- start/list/get/reply, blokada (UserBlock, w obu kierunkach), ukrycie
  rozmowy u siebie (nowa wiadomosc przywraca ja obu stronom). Dostep tylko
  dla stron rozmowy, filtr niedozwolonych slow jak w reszcie serwisu.
- Po zatwierdzeniu transakcji odbiorca dostaje powiadomienie w serwisie
  i - jesli ma wlaczone powiadomienia o wiadomosciach - e-mail (w tle).
- /api/messages/unread-count wlicza rozmowy o ogloszeniach.
- Zgloszenie uzytkownika z rozmowy: zgloszonego, ogloszenie i tresc rozmowy
  serwer bierze z bazy, nie z przegladarki.

Frontend:
- Adresy /wiadomosci/:conversationId, /wiadomosci/administracja
  i /wiadomosci/nowa/:listingId zamiast rozmowy w location.state.
- Lista rozmow odswiezana co 30 s, otwarta rozmowa co 4 s.
- Zdjecia w wiadomosciach usuniete - serwer ich nie przyjmuje (rozmowa
  z administracja i tak je gubila).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 23:15:06 +02:00
parent d655bcc9b1
commit 5eee33b986
20 changed files with 1267 additions and 488 deletions
@@ -3,12 +3,15 @@ package pl.polskalokalnie;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
// uzytkownika Spring Security (i mylacy log "Using generated security password").
// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu.
// @EnableAsync - e-mail o nowej wiadomosci wychodzi w tle, zeby SMTP nie opoznial wysylki.
@EnableScheduling
@EnableAsync
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
public class PolskaLokalnieApplication {
@@ -104,7 +104,8 @@ public class AdminStatsService {
List<Message> messagesToAdmin = adminId == null
? List.of()
: messages.stream()
.filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
.filter(message -> message.getConversationId() == null
&& message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
.toList();
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
@@ -4,6 +4,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.util.HtmlUtils;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
@@ -90,6 +91,21 @@ public class AccountEmailService {
approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link);
}
/** Nowa wiadomosc w rozmowie o ogloszeniu. Tresc pochodzi od uzytkownika, wiec jest escapowana. */
public void sendNewMessage(String email, String name, String senderName, String listingTitle,
String content, Long conversationId) {
String link = baseUrl + "/wiadomosci/" + conversationId;
String safeContent = HtmlUtils.htmlEscape(content == null ? "" : content).replace("\n", "<br>");
String body = "<p><strong>" + HtmlUtils.htmlEscape(senderName) + "</strong> napisał(a) w sprawie ogłoszenia"
+ " <strong>„" + HtmlUtils.htmlEscape(listingTitle) + "”</strong>:</p>"
+ "<blockquote style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;"
+ "border-radius:6px;\">" + safeContent + "</blockquote>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Odpowiedz w serwisie - nie podawaj w wiadomościach danych"
+ " logowania, numeru konta ani kodów BLIK.</p>";
send(email, "Nowa wiadomość w sprawie: " + listingTitle,
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
}
private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled()
@@ -0,0 +1,137 @@
package pl.polskalokalnie.message;
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;
/**
* Rozmowa o konkretnym ogloszeniu: jeden watek na pare (ogloszenie, kupujacy). Sprzedajacy to
* wlasciciel ogloszenia w chwili zalozenia rozmowy. Wiadomosci leza w tabeli messages
* z ustawionym conversationId.
*
* Tytul ogloszenia jest zapisany w rozmowie, bo ogloszenie moze zostac usuniete - historia
* rozmowy ma wtedy nadal czytelny temat.
*/
@Entity
@Table(name = "conversations", uniqueConstraints = @UniqueConstraint(columnNames = {"listingId", "buyerId"}))
public class Conversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long listingId;
@Column(nullable = false, length = 300)
private String listingTitle;
@Column(nullable = false)
private Long buyerId;
@Column(nullable = false)
private Long sellerId;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant lastMessageAt;
// Ukrycie rozmowy dziala osobno dla kazdej strony; nowa wiadomosc przywraca ja obu.
@Column(nullable = false)
private boolean buyerDeleted = false;
@Column(nullable = false)
private boolean sellerDeleted = false;
@PrePersist
void onCreate() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (lastMessageAt == null) {
lastMessageAt = now;
}
}
public boolean isParticipant(Long userId) {
return buyerId.equals(userId) || sellerId.equals(userId);
}
public Long otherParticipant(Long userId) {
return buyerId.equals(userId) ? sellerId : buyerId;
}
public boolean isDeletedFor(Long userId) {
return buyerId.equals(userId) ? buyerDeleted : sellerDeleted;
}
public void markDeletedFor(Long userId) {
if (buyerId.equals(userId)) {
buyerDeleted = true;
} else {
sellerDeleted = true;
}
}
public void restoreForBoth() {
buyerDeleted = false;
sellerDeleted = false;
}
public Long getId() {
return id;
}
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 Long getBuyerId() {
return buyerId;
}
public void setBuyerId(Long buyerId) {
this.buyerId = buyerId;
}
public Long getSellerId() {
return sellerId;
}
public void setSellerId(Long sellerId) {
this.sellerId = sellerId;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Instant lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
}
@@ -0,0 +1,78 @@
package pl.polskalokalnie.message;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
import pl.polskalokalnie.message.ConversationDtos.StartConversationRequest;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/** Rozmowy miedzy uzytkownikami o ogloszeniach. Wszystkie trasy wymagaja zalogowania. */
@RestController
@RequestMapping("/api/conversations")
public class ConversationController {
private final ConversationService conversationService;
private final UserRepository userRepository;
public ConversationController(ConversationService conversationService, UserRepository userRepository) {
this.conversationService = conversationService;
this.userRepository = userRepository;
}
@PostMapping
public ConversationDetail start(Authentication authentication, @Valid @RequestBody StartConversationRequest request) {
return conversationService.start(currentUser(authentication), request.listingId(), request.content());
}
@GetMapping
public List<ConversationSummary> list(Authentication authentication) {
return conversationService.list(currentUser(authentication));
}
@GetMapping("/{id}")
public ConversationDetail get(Authentication authentication, @PathVariable Long id) {
return conversationService.get(currentUser(authentication), id);
}
@PostMapping("/{id}/messages")
public ConversationDetail reply(Authentication authentication, @PathVariable Long id,
@Valid @RequestBody SendMessageRequest request) {
return conversationService.reply(currentUser(authentication), id, request.content());
}
@PostMapping("/{id}/block")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void block(Authentication authentication, @PathVariable Long id) {
conversationService.block(currentUser(authentication), id);
}
@DeleteMapping("/{id}/block")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unblock(Authentication authentication, @PathVariable Long id) {
conversationService.unblock(currentUser(authentication), id);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(Authentication authentication, @PathVariable Long id) {
conversationService.deleteForUser(currentUser(authentication), id);
}
private AppUser currentUser(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
}
}
@@ -0,0 +1,74 @@
package pl.polskalokalnie.message;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** Obiekty wymiany danych dla rozmow o ogloszeniach (/api/conversations). */
public final class ConversationDtos {
private ConversationDtos() {
}
public record StartConversationRequest(
@NotNull Long listingId,
@NotBlank @Size(max = 2000) String content
) {
}
/** Skrot ogloszenia w naglowku rozmowy; pola ogloszenia sa null, gdy zostalo usuniete. */
public record ConversationListing(
Long listingId,
String title,
boolean available,
BigDecimal price,
String city,
String district,
Double area,
Integer rooms,
String floor,
String buildingFloors,
String thumbnail
) {
}
public record ConversationSummary(
Long id,
ConversationListing listing,
Long otherUserId,
String otherUserName,
boolean otherUserExists,
boolean selling,
String lastMessage,
boolean lastMessageMine,
long unreadCount,
Instant lastMessageAt
) {
}
/** read ma znaczenie tylko dla wlasnych wiadomosci - czy druga strona je przeczytala. */
public record ConversationMessage(
Long id,
String content,
boolean mine,
boolean read,
Instant createdAt
) {
}
public record ConversationDetail(
Long id,
ConversationListing listing,
Long otherUserId,
String otherUserName,
boolean otherUserExists,
boolean selling,
boolean blockedByMe,
boolean blocked,
List<ConversationMessage> messages
) {
}
}
@@ -0,0 +1,68 @@
package pl.polskalokalnie.message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import pl.polskalokalnie.auth.AccountEmailService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Powiadamia odbiorce o nowej wiadomosci w rozmowie o ogloszeniu - dopiero po zatwierdzeniu
* transakcji, zeby nie powiadamiac o wiadomosci, ktora ostatecznie sie nie zapisala. Dziala w tle:
* wolny serwer SMTP nie opoznia wysylki wiadomosci, a blad wysylki nie cofa jej zapisu.
*/
@Component
public class ConversationMessageNotifier {
private static final Logger log = LoggerFactory.getLogger(ConversationMessageNotifier.class);
private final NotificationService notificationService;
private final AccountEmailService accountEmailService;
private final UserSettingsRepository userSettingsRepository;
public ConversationMessageNotifier(NotificationService notificationService,
AccountEmailService accountEmailService,
UserSettingsRepository userSettingsRepository) {
this.notificationService = notificationService;
this.accountEmailService = accountEmailService;
this.userSettingsRepository = userSettingsRepository;
}
public record MessageSentEvent(
Long conversationId,
Long recipientId,
String recipientEmail,
String recipientName,
String senderName,
String listingTitle,
String content
) {
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onMessageSent(MessageSentEvent event) {
try {
String preview = event.content().length() > 90 ? event.content().substring(0, 90) + "…" : event.content();
notificationService.create(event.recipientEmail(), "messages", "message",
"Nowa wiadomość: " + event.listingTitle(),
event.senderName() + ": " + preview,
"messages:conversation:" + event.conversationId(), null);
boolean wantsEmail = userSettingsRepository.findByUserId(event.recipientId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (wantsEmail) {
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
event.listingTitle(), event.content(), event.conversationId());
}
} catch (RuntimeException ex) {
log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage());
}
}
}
@@ -0,0 +1,17 @@
package pl.polskalokalnie.message;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ConversationRepository extends JpaRepository<Conversation, Long> {
Optional<Conversation> findByListingIdAndBuyerId(Long listingId, Long buyerId);
// Rozmowy widoczne dla uzytkownika - bez tych, ktore ukryl u siebie.
@Query("select c from Conversation c where (c.buyerId = :userId and c.buyerDeleted = false) "
+ "or (c.sellerId = :userId and c.sellerDeleted = false) order by c.lastMessageAt desc")
List<Conversation> findVisibleFor(@Param("userId") Long userId);
}
@@ -0,0 +1,267 @@
package pl.polskalokalnie.message;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.context.ApplicationEventPublisher;
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.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
import pl.polskalokalnie.message.ConversationDtos.ConversationListing;
import pl.polskalokalnie.message.ConversationDtos.ConversationMessage;
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
import pl.polskalokalnie.message.ConversationMessageNotifier.MessageSentEvent;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/**
* Rozmowy miedzy uzytkownikami o ogloszeniach. Watek zaklada kupujacy pierwsza wiadomoscia
* z ogloszenia; sprzedajacy odpowiada w istniejacym watku. Dostep maja wylacznie obie strony.
*/
@Service
public class ConversationService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserBlockRepository userBlockRepository;
private final UserRepository userRepository;
private final ListingRepository listingRepository;
private final TextModerationService textModerationService;
private final ApplicationEventPublisher eventPublisher;
public ConversationService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserBlockRepository userBlockRepository,
UserRepository userRepository,
ListingRepository listingRepository,
TextModerationService textModerationService,
ApplicationEventPublisher eventPublisher) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userBlockRepository = userBlockRepository;
this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.eventPublisher = eventPublisher;
}
@Transactional
public ConversationDetail start(AppUser me, Long listingId, String content) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
AppUser seller = listing.getOwnerEmail() == null ? null
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
if (seller == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Z autorem tego ogłoszenia nie można się skontaktować");
}
if (seller.getId().equals(me.getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie możesz napisać wiadomości do samego siebie");
}
requireNotBlocked(me.getId(), seller.getId());
Conversation conversation = conversationRepository.findByListingIdAndBuyerId(listingId, me.getId())
.orElseGet(() -> {
Conversation created = new Conversation();
created.setListingId(listingId);
created.setListingTitle(listing.getTitle());
created.setBuyerId(me.getId());
created.setSellerId(seller.getId());
return conversationRepository.save(created);
});
append(conversation, me, seller, content);
return detail(conversation, me.getId());
}
@Transactional(readOnly = true)
public List<ConversationSummary> list(AppUser me) {
List<Conversation> conversations = conversationRepository.findVisibleFor(me.getId());
if (conversations.isEmpty()) {
return List.of();
}
Set<Long> ids = conversations.stream().map(Conversation::getId).collect(Collectors.toSet());
Map<Long, Message> latest = messageRepository.findLatestPerConversation(ids).stream()
.collect(Collectors.toMap(Message::getConversationId, Function.identity()));
Map<Long, Long> unread = new HashMap<>();
for (Object[] row : messageRepository.countUnreadPerConversation(ids, me.getId())) {
unread.put((Long) row[0], (Long) row[1]);
}
Map<Long, AppUser> users = userRepository.findAllById(conversations.stream()
.map(conversation -> conversation.otherParticipant(me.getId())).collect(Collectors.toSet()))
.stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
Map<Long, PropertyListing> listings = listingRepository.findAllById(conversations.stream()
.map(Conversation::getListingId).collect(Collectors.toSet()))
.stream().collect(Collectors.toMap(PropertyListing::getId, Function.identity()));
return conversations.stream().map(conversation -> {
AppUser other = users.get(conversation.otherParticipant(me.getId()));
Message last = latest.get(conversation.getId());
return new ConversationSummary(
conversation.getId(),
listingOf(conversation, listings.get(conversation.getListingId())),
conversation.otherParticipant(me.getId()),
displayName(other),
other != null,
conversation.getSellerId().equals(me.getId()),
last == null ? "" : last.getContent(),
last != null && last.getSenderId().equals(me.getId()),
unread.getOrDefault(conversation.getId(), 0L),
conversation.getLastMessageAt());
}).toList();
}
/** Otwarcie rozmowy oznacza wiadomosci od drugiej strony jako przeczytane. */
@Transactional
public ConversationDetail get(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
if (conversation.isDeletedFor(me.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy");
}
messageRepository.markConversationMessagesRead(conversation.getId(), me.getId());
return detail(conversation, me.getId());
}
@Transactional
public ConversationDetail reply(AppUser me, Long conversationId, String content) {
Conversation conversation = requireParticipant(me, conversationId);
AppUser other = userRepository.findById(conversation.otherParticipant(me.getId()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto rozmówcy zostało usunięte"));
requireNotBlocked(me.getId(), other.getId());
append(conversation, me, other, content);
return detail(conversation, me.getId());
}
@Transactional
public void block(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
Long otherId = conversation.otherParticipant(me.getId());
if (!userBlockRepository.existsByBlockerIdAndBlockedId(me.getId(), otherId)) {
userBlockRepository.save(new UserBlock(me.getId(), otherId));
}
}
@Transactional
public void unblock(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
userBlockRepository.deleteByBlockerIdAndBlockedId(me.getId(), conversation.otherParticipant(me.getId()));
}
/** Ukrywa rozmowe tylko u wywolujacego; nowa wiadomosc przywroci ja obu stronom. */
@Transactional
public void deleteForUser(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
conversation.markDeletedFor(me.getId());
}
/** Licznik nieprzeczytanych wiadomosci we wszystkich rozmowach o ogloszeniach. */
public long unreadCount(AppUser me) {
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
}
private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) {
textModerationService.validateOrThrow(content);
String trimmed = content.trim();
Message message = new Message();
message.setConversationId(conversation.getId());
message.setSenderId(sender.getId());
message.setRecipientId(recipient.getId());
message.setContent(trimmed);
messageRepository.save(message);
conversation.setLastMessageAt(Instant.now());
conversation.restoreForBoth();
eventPublisher.publishEvent(new MessageSentEvent(
conversation.getId(),
recipient.getId(),
recipient.getEmail(),
recipient.getFullName(),
displayName(sender),
conversation.getListingTitle(),
trimmed));
}
private ConversationDetail detail(Conversation conversation, Long meId) {
Long otherId = conversation.otherParticipant(meId);
AppUser other = userRepository.findById(otherId).orElse(null);
PropertyListing listing = listingRepository.findById(conversation.getListingId()).orElse(null);
boolean blockedByMe = userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId);
boolean blocked = blockedByMe || userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId);
List<ConversationMessage> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId())
.stream()
.map(message -> new ConversationMessage(
message.getId(),
message.getContent(),
message.getSenderId().equals(meId),
message.isRead(),
message.getCreatedAt()))
.toList();
return new ConversationDetail(
conversation.getId(),
listingOf(conversation, listing),
otherId,
displayName(other),
other != null,
conversation.getSellerId().equals(meId),
blockedByMe,
blocked,
messages);
}
private ConversationListing listingOf(Conversation conversation, PropertyListing listing) {
if (listing == null) {
return new ConversationListing(conversation.getListingId(), conversation.getListingTitle(), false,
null, null, null, null, null, null, null, null);
}
return new ConversationListing(
listing.getId(),
listing.getTitle(),
listing.getStatus() == ListingStatus.APPROVED,
listing.getPrice(),
listing.getCity(),
listing.getDistrict(),
listing.getArea(),
listing.getRooms(),
listing.getFloor(),
listing.getBuildingFloors(),
listing.getCoverPhoto());
}
private Conversation requireParticipant(AppUser me, Long conversationId) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
if (!conversation.isParticipant(me.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
}
return conversation;
}
private void requireNotBlocked(Long meId, Long otherId) {
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
}
if (userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz pisać do tego użytkownika.");
}
}
private static String displayName(AppUser user) {
if (user == null) {
return "Konto usunięte";
}
if (user.getFullName() != null && !user.getFullName().isBlank()) {
return user.getFullName().trim();
}
return user.getNick() != null ? user.getNick() : "Użytkownik";
}
}
@@ -23,6 +23,9 @@ public class Message {
@Column(nullable = false)
private Long recipientId;
// Rozmowa o ogloszeniu miedzy uzytkownikami; null = rozmowa z administracja serwisu.
private Long conversationId;
@Column(nullable = false, length = 2000)
private String content;
@@ -59,6 +62,14 @@ public class Message {
this.recipientId = recipientId;
}
public Long getConversationId() {
return conversationId;
}
public void setConversationId(Long conversationId) {
this.conversationId = conversationId;
}
public String getContent() {
return content;
}
@@ -18,10 +18,13 @@ import pl.polskalokalnie.user.UserRepository;
public class MessageController {
private final MessageService messageService;
private final ConversationService conversationService;
private final UserRepository userRepository;
public MessageController(MessageService messageService, UserRepository userRepository) {
public MessageController(MessageService messageService, ConversationService conversationService,
UserRepository userRepository) {
this.messageService = messageService;
this.conversationService = conversationService;
this.userRepository = userRepository;
}
@@ -39,11 +42,13 @@ public class MessageController {
return messageService.send(me.getId(), admin.getId(), request.content());
}
// Wszystkie nieprzeczytane: od administracji oraz z rozmow o ogloszeniach.
@GetMapping("/unread-count")
public UnreadCountResponse unreadCount(Authentication authentication) {
AppUser me = currentUser(authentication);
AppUser admin = messageService.resolveAdmin();
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId()));
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId())
+ conversationService.unreadCount(me));
}
private AppUser currentUser(Authentication authentication) {
@@ -1,5 +1,6 @@
package pl.polskalokalnie.message;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@@ -9,14 +10,38 @@ import org.springframework.transaction.annotation.Transactional;
public interface MessageRepository extends JpaRepository<Message, Long> {
@Query("select m from Message m where (m.senderId = :first and m.recipientId = :second) "
+ "or (m.senderId = :second and m.recipientId = :first) order by m.createdAt asc")
// --- Rozmowa z administracja (wiadomosci bez conversationId) ---
@Query("select m from Message m where m.conversationId is null and ((m.senderId = :first and m.recipientId = :second) "
+ "or (m.senderId = :second and m.recipientId = :first)) order by m.createdAt asc")
List<Message> findConversation(@Param("first") Long first, @Param("second") Long second);
long countByRecipientIdAndSenderIdAndReadFalse(Long recipientId, Long senderId);
long countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(Long recipientId, Long senderId);
@Modifying
@Transactional
@Query("update Message m set m.read = true where m.recipientId = :recipientId and m.senderId = :senderId and m.read = false")
@Query("update Message m set m.read = true where m.conversationId is null and m.recipientId = :recipientId "
+ "and m.senderId = :senderId and m.read = false")
void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId);
// --- Rozmowy o ogloszeniach miedzy uzytkownikami ---
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
// Ostatnia wiadomosc kazdej rozmowy jednym zapytaniem - bez N+1 na liscie rozmow.
@Query("select m from Message m where m.id in "
+ "(select max(m2.id) from Message m2 where m2.conversationId in :ids group by m2.conversationId)")
List<Message> findLatestPerConversation(@Param("ids") Collection<Long> ids);
@Query("select m.conversationId, count(m) from Message m where m.conversationId in :ids "
+ "and m.recipientId = :userId and m.read = false group by m.conversationId")
List<Object[]> countUnreadPerConversation(@Param("ids") Collection<Long> ids, @Param("userId") Long userId);
long countByRecipientIdAndReadFalseAndConversationIdIsNotNull(Long recipientId);
@Modifying
@Transactional
@Query("update Message m set m.read = true where m.conversationId = :conversationId "
+ "and m.recipientId = :recipientId and m.read = false")
void markConversationMessagesRead(@Param("conversationId") Long conversationId, @Param("recipientId") Long recipientId);
}
@@ -83,6 +83,6 @@ public class MessageService {
}
public long countUnreadFrom(Long recipientId, Long senderId) {
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
return messageRepository.countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(recipientId, senderId);
}
}
@@ -0,0 +1,57 @@
package pl.polskalokalnie.message;
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;
/** Blokada miedzy uzytkownikami - wstrzymuje wiadomosci w obu kierunkach. */
@Entity
@Table(name = "user_blocks", uniqueConstraints = @UniqueConstraint(columnNames = {"blockerId", "blockedId"}))
public class UserBlock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long blockerId;
@Column(nullable = false)
private Long blockedId;
@Column(nullable = false, updatable = false)
private Instant createdAt;
protected UserBlock() {
}
public UserBlock(Long blockerId, Long blockedId) {
this.blockerId = blockerId;
this.blockedId = blockedId;
}
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public Long getBlockerId() {
return blockerId;
}
public Long getBlockedId() {
return blockedId;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
public interface UserBlockRepository extends JpaRepository<UserBlock, Long> {
boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
@Transactional
void deleteByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
}
@@ -7,6 +7,10 @@ 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.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.Message;
import pl.polskalokalnie.message.MessageRepository;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@@ -20,10 +24,15 @@ public class UserReportService {
private final UserReportRepository userReportRepository;
private final UserRepository userRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository) {
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
ConversationRepository conversationRepository, MessageRepository messageRepository) {
this.userReportRepository = userReportRepository;
this.userRepository = userRepository;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
}
@Transactional
@@ -42,9 +51,20 @@ public class UserReportService {
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());
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje.
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
Conversation conversation = reporterConversation(request.conversationId(), reporter);
AppUser reported;
if (conversation != null) {
reported = userRepository.findById(conversation.otherParticipant(reporter.getId())).orElse(null);
report.setListingId(conversation.getListingId());
report.setListingTitle(conversation.getListingTitle());
report.setConversation(conversationFromDatabase(conversation, reporter.getId()));
} else {
reported = findReported(request.reportedEmail());
}
if (reported != null) {
report.setReportedEmail(reported.getEmail());
report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName());
@@ -123,6 +143,33 @@ public class UserReportService {
return cleaned;
}
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
private Conversation reporterConversation(String conversationId, AppUser reporter) {
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
return null;
}
return conversationRepository.findById(Long.valueOf(conversationId.trim()))
.filter(conversation -> conversation.isParticipant(reporter.getId()))
.orElse(null);
}
private List<UserReportMessage> conversationFromDatabase(Conversation conversation, Long reporterId) {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId());
List<Message> tail = messages.size() > MAX_CONVERSATION_MESSAGES
? messages.subList(messages.size() - MAX_CONVERSATION_MESSAGES, messages.size())
: messages;
List<UserReportMessage> snapshot = new ArrayList<>();
for (Message source : tail) {
UserReportMessage message = new UserReportMessage();
message.setFromReporter(source.getSenderId().equals(reporterId));
message.setContent(source.getContent());
message.setSentAt(source.getCreatedAt());
message.setHasPhoto(false);
snapshot.add(message);
}
return snapshot;
}
private AppUser findReported(String email) {
if (email == null || email.isBlank()) {
return null;
+386 -396
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -45,6 +45,10 @@ export const ROUTES = {
notifications: '/powiadomienia',
favorites: '/ulubione',
messages: '/wiadomosci',
// Rozmowa o ogloszeniu (numer z API) albo "administracja" - rozmowa z obsluga serwisu.
messagesConversation: '/wiadomosci/:conversationId',
// Nowa rozmowa z ogloszenia - watek powstaje na serwerze dopiero z pierwsza wiadomoscia.
messagesNew: '/wiadomosci/nowa/:listingId',
admin: '/admin',
listingDetail: '/oferta/:id',
publicProfile: '/profil/:id',
@@ -96,6 +100,17 @@ export function publicProfilePath(id: number): string {
return `/profil/${id}`;
}
// Identyfikator rozmowy z obsluga serwisu w adresie /wiadomosci/:conversationId.
export const ADMIN_CONVERSATION_SLUG = 'administracja';
export function conversationPath(id: number | typeof ADMIN_CONVERSATION_SLUG): string {
return `/wiadomosci/${id}`;
}
export function newConversationPath(listingId: number): string {
return `/wiadomosci/nowa/${listingId}`;
}
// Dedykowana strona edycji wlasnego ogloszenia.
export function listingEditPath(id: number): string {
return `/edytuj-ogloszenie/${id}`;
@@ -217,6 +232,8 @@ export const PROTECTED_PATHS: string[] = [
ROUTES.notifications,
ROUTES.favorites,
ROUTES.messages,
ROUTES.messagesConversation,
ROUTES.messagesNew,
ROUTES.comparison,
ROUTES.guideAdd,
];
+1 -71
View File
@@ -16974,76 +16974,6 @@ a.city-card:hover {
margin: 0;
}
.message-photo {
background: transparent;
border: 0;
border-radius: 10px;
cursor: zoom-in;
display: block;
margin-bottom: 8px;
padding: 0;
}
.message-photo img {
border-radius: 10px;
display: block;
max-height: 260px;
max-width: 100%;
object-fit: cover;
transition: opacity 0.12s ease;
}
.message-photo:hover img {
opacity: 0.92;
}
.message-photo:focus-visible {
outline: 2px solid #17a764;
outline-offset: 2px;
}
.messages-composer-attachment {
align-items: center;
background: #f4f8fb;
border: 1px solid #dbe6f0;
border-radius: 10px;
display: grid;
gap: 10px;
grid-template-columns: 40px minmax(0, 1fr) 28px;
margin: 8px 0 0;
padding: 6px 8px;
}
.messages-composer-attachment img {
border-radius: 8px;
height: 40px;
object-fit: cover;
width: 40px;
}
.messages-composer-attachment span {
color: #33506e;
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-composer-attachment button {
background: transparent;
border: 0;
border-radius: 6px;
color: #5f7289;
font-size: 20px;
line-height: 1;
min-height: 28px;
}
.messages-composer-attachment button:hover {
background: #e6eef7;
}
.message-business-card {
background:
radial-gradient(120% 140% at 0% 0%, rgba(121, 186, 255, 0.18) 0%, rgba(121, 186, 255, 0) 52%),
@@ -17222,7 +17152,7 @@ a.city-card:hover {
border-top: 1px solid #e8eef5;
display: grid;
gap: 8px;
grid-template-columns: 34px 78px minmax(0, 1fr) 34px;
grid-template-columns: 78px minmax(0, 1fr) 34px;
padding: 0 10px;
}
+25 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { ROUTES } from '../src/routes';
import { ROUTES, ADMIN_CONVERSATION_SLUG, conversationPath, newConversationPath } from '../src/routes';
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
@@ -28,6 +28,8 @@ const PROTECTED_ROUTES = [
ROUTES.account,
ROUTES.favorites,
ROUTES.messages,
conversationPath(ADMIN_CONVERSATION_SLUG),
newConversationPath(1),
ROUTES.add,
ROUTES.admin,
`${ROUTES.admin}/leady`,
@@ -373,3 +375,25 @@ test.describe('Naglowek', () => {
await expect(brand).toContainText('ogłoszenia za darmo');
});
});
test.describe('Wiadomosci', () => {
test('"Napisz wiadomosc" w ogloszeniu otwiera rozmowe pod wlasnym adresem', async ({ page }) => {
await login(page);
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href');
await goto(page, href!);
await page.getByRole('button', { name: /Napisz wiadomość/ }).first().click();
await page.waitForFunction(() => /^\/wiadomosci\/nowa\/\d+$/.test(window.location.pathname));
// Adres szkicu dziala po odswiezeniu - rozmowa nie zalezy od stanu nawigacji.
await page.reload();
await expect(page.locator('.messages-composer input[type=text]')).toBeVisible();
});
test('nieistniejaca rozmowa wraca na liste wiadomosci', async ({ page }) => {
await login(page);
await goto(page, conversationPath(999999));
await page.waitForFunction(() => window.location.pathname === '/wiadomosci');
});
});