Dodaj prawdziwe wiadomosci miedzy uzytkownikami o ogloszeniach
Deploy Polska Lokalnie / deploy (push) Successful in 12s
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:
@@ -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;
|
||||
|
||||
+394
-404
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user