diff --git a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java index c48c380..8f87aaf 100644 --- a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java +++ b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java @@ -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 { diff --git a/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java index 863e6ef..6636730 100644 --- a/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java +++ b/backend/src/main/java/pl/polskalokalnie/admin/AdminStatsService.java @@ -104,7 +104,8 @@ public class AdminStatsService { List 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( diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java index 35bde1a..3856190 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java @@ -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", "
"); + String body = "

" + HtmlUtils.htmlEscape(senderName) + " napisał(a) w sprawie ogłoszenia" + + " „" + HtmlUtils.htmlEscape(listingTitle) + "”:

" + + "
" + safeContent + "
" + + "

Odpowiedz w serwisie - nie podawaj w wiadomościach danych" + + " logowania, numeru konta ani kodów BLIK.

"; + 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() diff --git a/backend/src/main/java/pl/polskalokalnie/message/Conversation.java b/backend/src/main/java/pl/polskalokalnie/message/Conversation.java new file mode 100644 index 0000000..0fd1f07 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/Conversation.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationController.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationController.java new file mode 100644 index 0000000..a7ffd87 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationController.java @@ -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 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)); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationDtos.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationDtos.java new file mode 100644 index 0000000..56ddff7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationDtos.java @@ -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 messages + ) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java new file mode 100644 index 0000000..16676af --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationMessageNotifier.java @@ -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()); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationRepository.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationRepository.java new file mode 100644 index 0000000..7bb22ed --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationRepository.java @@ -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 { + + Optional 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 findVisibleFor(@Param("userId") Long userId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java new file mode 100644 index 0000000..3c7e637 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java @@ -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 list(AppUser me) { + List conversations = conversationRepository.findVisibleFor(me.getId()); + if (conversations.isEmpty()) { + return List.of(); + } + Set ids = conversations.stream().map(Conversation::getId).collect(Collectors.toSet()); + Map latest = messageRepository.findLatestPerConversation(ids).stream() + .collect(Collectors.toMap(Message::getConversationId, Function.identity())); + Map unread = new HashMap<>(); + for (Object[] row : messageRepository.countUnreadPerConversation(ids, me.getId())) { + unread.put((Long) row[0], (Long) row[1]); + } + Map users = userRepository.findAllById(conversations.stream() + .map(conversation -> conversation.otherParticipant(me.getId())).collect(Collectors.toSet())) + .stream().collect(Collectors.toMap(AppUser::getId, Function.identity())); + Map 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 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"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/Message.java b/backend/src/main/java/pl/polskalokalnie/message/Message.java index 396e917..7002705 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/Message.java +++ b/backend/src/main/java/pl/polskalokalnie/message/Message.java @@ -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; } diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageController.java b/backend/src/main/java/pl/polskalokalnie/message/MessageController.java index 0913d00..fd30062 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageController.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageController.java @@ -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) { diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java b/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java index 13412bc..f1d43d2 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageRepository.java @@ -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 { - @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 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 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 findLatestPerConversation(@Param("ids") Collection 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 countUnreadPerConversation(@Param("ids") Collection 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); } diff --git a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java index 7b0d2e6..d2dfbf4 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/MessageService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/MessageService.java @@ -83,6 +83,6 @@ public class MessageService { } public long countUnreadFrom(Long recipientId, Long senderId) { - return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); + return messageRepository.countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(recipientId, senderId); } } diff --git a/backend/src/main/java/pl/polskalokalnie/message/UserBlock.java b/backend/src/main/java/pl/polskalokalnie/message/UserBlock.java new file mode 100644 index 0000000..e74f321 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/UserBlock.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/UserBlockRepository.java b/backend/src/main/java/pl/polskalokalnie/message/UserBlockRepository.java new file mode 100644 index 0000000..f836f03 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/message/UserBlockRepository.java @@ -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 { + + boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + @Transactional + void deleteByBlockerIdAndBlockedId(Long blockerId, Long blockedId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java index 0fe0337..a89f0b6 100644 --- a/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java +++ b/backend/src/main/java/pl/polskalokalnie/report/UserReportService.java @@ -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 conversationFromDatabase(Conversation conversation, Long reporterId) { + List messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId()); + List tail = messages.size() > MAX_CONVERSATION_MESSAGES + ? messages.subList(messages.size() - MAX_CONVERSATION_MESSAGES, messages.size()) + : messages; + List 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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f20b1e6..2430a6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { Fragment, type CSSProperties, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, conversationPath, newConversationPath, ADMIN_CONVERSATION_SLUG, publicProfileNickPath, listingEditPath, mapPath, buyCityPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; import { guideContents, type GuideContent, type GuideBlock } from './guides-data'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; @@ -56,6 +56,74 @@ type MessageThreadTarget = { listing: MessageThreadListing; }; +// Rozmowy o ogloszeniach z /api/conversations. Pola ogloszenia sa null, gdy zostalo usuniete. +type ApiConversationListing = { + listingId: number; + title: string; + available: boolean; + price: number | null; + city: string | null; + district: string | null; + area: number | null; + rooms: number | null; + floor: string | null; + buildingFloors: string | null; + thumbnail: string | null; +}; + +type ApiConversationBase = { + id: number; + listing: ApiConversationListing; + otherUserId: number; + otherUserName: string; + otherUserExists: boolean; + selling: boolean; +}; + +type ApiConversationSummary = ApiConversationBase & { + lastMessage: string; + lastMessageMine: boolean; + unreadCount: number; + lastMessageAt: string; +}; + +type ApiConversationDetail = ApiConversationBase & { + blockedByMe: boolean; + blocked: boolean; + messages: { id: number; content: string; mine: boolean; read: boolean; createdAt: string }[]; +}; + +// Wspolne pola watku w widoku wiadomosci dla rozmowy z serwera. +function conversationThreadBase(conversation: ApiConversationBase) { + const { listing } = conversation; + const status = !conversation.otherUserExists + ? 'Konto rozmówcy zostało usunięte' + : conversation.selling ? 'Pyta o Twoje ogłoszenie' : 'Sprzedający'; + return { + id: `conv-${conversation.id}`, + conversationId: conversation.id, + name: conversation.otherUserName, + avatarImage: listing.thumbnail || heroImage, + status: listing.available ? status : `${status} • ogłoszenie nieaktywne`, + contactUserId: conversation.otherUserExists ? conversation.otherUserId : null, + selling: conversation.selling, + listing: listing.price == null || listing.area == null || listing.rooms == null + ? null + : { + listingId: listing.listingId, + title: listing.title, + price: listing.price, + city: listing.city ?? '', + district: listing.district, + area: listing.area, + rooms: listing.rooms, + floor: listing.floor, + buildingFloors: listing.buildingFloors, + thumbnail: listing.thumbnail, + }, + }; +} + export type ApiOfferType = 'SALE' | 'RENT'; export type ApiPropertyType = 'APARTMENT' | 'HOUSE'; export type ApiListingStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED'; @@ -3168,8 +3236,9 @@ function App() { navigate(listingPath(id)); }; + // Rozmowa o ogloszeniu ma wlasny adres - identyfikator ogloszenia w URL, nie w stanie nawigacji. const openMessagesWithTarget = (target: MessageThreadTarget) => { - navigate(ROUTES.messages, { state: { threadTarget: target } }); + navigate(newConversationPath(target.listing.listingId)); }; // Klik w powiadomienie: oznacz jako przeczytane (znika z dzwonka, licznik -1) i przejdź do właściwego miejsca. @@ -3187,6 +3256,15 @@ function App() { return; } } + if (link.startsWith('messages:conversation:')) { + const id = Number(link.slice('messages:conversation:'.length)); + navigate(Number.isFinite(id) && id > 0 ? conversationPath(id) : ROUTES.messages); + return; + } + if (link === 'messages:admin') { + navigate(conversationPath(ADMIN_CONVERSATION_SLUG)); + return; + } if (link.startsWith('messages')) { navigate(ROUTES.messages); return; @@ -3697,6 +3775,8 @@ function App() { }> } /> } /> + } /> + } /> (user, LISTING_PRICE_ALERTS_STORAGE_SUFFIX, []).length : 0; + // Admin tez moze sprzedawac, wiec licznik rozmow o ogloszeniach dotyczy kazdego konta. useEffect(() => { - if (!user || user.role === 'ADMIN') { + if (!user) { setMessagesUnreadCount(0); return; } @@ -24163,14 +24244,10 @@ function MessagesPage({ onOpenListing: (id: number) => void; }) { const navigate = useNavigate(); - const location = useLocation(); const { formatPrice, formatArea } = useDisplay(); - // Rozmowa otwarta z ogloszenia lub ulubionych przychodzi w location.state. - // Po zalozeniu watku czyscimy state, zeby powrot Wstecz nie tworzyl go ponownie. - const initialThreadTarget = (location.state as { threadTarget?: MessageThreadTarget } | null)?.threadTarget ?? null; - const onThreadTargetHandled = useCallback(() => { - navigate(location.pathname, { replace: true, state: null }); - }, [navigate, location.pathname]); + // Otwarta rozmowa wynika z adresu: /wiadomosci/:conversationId (numer z API albo "administracja") + // lub /wiadomosci/nowa/:listingId - szkic rozmowy z ogloszenia, zapisany z pierwsza wiadomoscia. + const { conversationId: conversationParam, listingId: newListingParam } = useParams(); type ChatMessage = { id: string; text: string; @@ -24179,8 +24256,6 @@ function MessagesPage({ sentAt: number; readByRecipient?: boolean; unread?: boolean; - attachmentName?: string; - attachmentUrl?: string; businessCard?: BusinessCard; }; @@ -24189,11 +24264,20 @@ function MessagesPage({ name: string; avatarImage: string; status: string; - // Konto rozmowcy - ustawione tylko dla rozmow zalozonych z realnego ogloszenia. - contactEmail?: string | null; + // Konto rozmowcy - link do jego profilu publicznego. contactUserId?: number | null; listing: MessageThreadListing | null; messages: ChatMessage[]; + // Rozmowa o ogloszeniu zapisana na serwerze. + conversationId?: number; + // Szkic nowej rozmowy - ogloszenie, ktorego dotyczy pierwsza wiadomosc. + draftListingId?: number; + // Rozmowa o moim ogloszeniu (pisze kupujacy). + selling?: boolean; + // Dane z listy rozmow, zanim wczytamy wiadomosci watku. + summary?: { preview: string; lastAt: number; unread: number }; + blocked?: boolean; + blockedByMe?: boolean; }; type ThreadFilter = 'all' | 'unread' | 'read'; @@ -24211,10 +24295,7 @@ function MessagesPage({ const [activeFilter, setActiveFilter] = useState('all'); const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [composerValue, setComposerValue] = useState(''); - // Wybrane zdjecie trzymamy juz jako dataURL, zeby pokazac podglad w oknie i w wiadomosci. - const [selectedAttachment, setSelectedAttachment] = useState<{ name: string; url: string } | null>(null); - // Zdjecie z rozmowy otwarte na pelnym ekranie. - const [previewPhoto, setPreviewPhoto] = useState<{ name: string; url: string } | null>(null); + const [isSending, setIsSending] = useState(false); const [moderationFeedback, setModerationFeedback] = useState(''); const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); // Pytanie o numer telefonu przed wyslaniem wizytowki z konta, ktore go nie ma. @@ -24223,7 +24304,6 @@ function MessagesPage({ const [phonePromptError, setPhonePromptError] = useState(''); const [isSavingPhonePrompt, setIsSavingPhonePrompt] = useState(false); const [isDeleteChatModalOpen, setIsDeleteChatModalOpen] = useState(false); - const [blockedThreadIds, setBlockedThreadIds] = useState([]); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [reportStep, setReportStep] = useState(1); const [selectedReportReason, setSelectedReportReason] = useState(reportReasons[0].id); @@ -24236,12 +24316,176 @@ function MessagesPage({ const [isSubmittingReport, setIsSubmittingReport] = useState(false); const [reportSubmitError, setReportSubmitError] = useState(''); const [threads, setThreads] = useState([]); - const [activeThreadId, setActiveThreadId] = useState(''); - const attachmentInputRef = useRef(null); + // Lista rozmow wczytana choc raz - do tego czasu nie wiemy, czy szkic ma juz swoja rozmowe. + const [conversationsLoaded, setConversationsLoaded] = useState(false); const reportAttachmentInputRef = useRef(null); const { user, updateProfile } = useAuth(); const ADMIN_THREAD_ID = 'admin-support'; + const routeThreadId = newListingParam + ? `draft-${newListingParam}` + : conversationParam === ADMIN_CONVERSATION_SLUG + ? ADMIN_THREAD_ID + : conversationParam ? `conv-${conversationParam}` : ''; + + const threadPath = (thread: ChatThread) => { + if (thread.id === ADMIN_THREAD_ID) { + return conversationPath(ADMIN_CONVERSATION_SLUG); + } + if (thread.conversationId) { + return conversationPath(thread.conversationId); + } + return thread.draftListingId ? newConversationPath(thread.draftListingId) : ROUTES.messages; + }; + + // Stan rozmowy z serwera -> watek w widoku. Wstawia go, jesli nie bylo go jeszcze na liscie. + const applyConversationDetail = useCallback((detail: ApiConversationDetail) => { + setThreads((current) => { + const id = `conv-${detail.id}`; + const messages: ChatMessage[] = detail.messages.map((message) => { + const sentAt = new Date(message.createdAt).getTime(); + return { + id: `conv-msg-${message.id}`, + ...chatContentParts(message.content), + time: chatTimeLabel(new Date(sentAt)), + mine: message.mine, + sentAt, + readByRecipient: message.mine ? message.read : undefined, + }; + }); + const last = messages[messages.length - 1]; + const thread: ChatThread = { + ...conversationThreadBase(detail), + messages, + summary: { preview: last?.text ?? '', lastAt: last?.sentAt ?? 0, unread: 0 }, + blocked: detail.blocked, + blockedByMe: detail.blockedByMe, + }; + return current.some((item) => item.id === id) + ? current.map((item) => (item.id === id ? thread : item)) + : [...current, thread]; + }); + }, []); + + const routeThreadIdRef = useRef(routeThreadId); + routeThreadIdRef.current = routeThreadId; + + // Lista rozmow o ogloszeniach - odswiezana co 30 s, jak w Gieldzie Komunalnej. + const loadConversations = useCallback(() => ( + apiFetch('/conversations') + .then((summaries) => { + setThreads((current) => { + const listed = summaries.map((summary): ChatThread => { + const existing = current.find((item) => item.id === `conv-${summary.id}`); + return { + ...conversationThreadBase(summary), + messages: existing?.messages ?? [], + summary: { + preview: summary.lastMessage, + lastAt: new Date(summary.lastMessageAt).getTime(), + unread: summary.unreadCount, + }, + blocked: existing?.blocked, + blockedByMe: existing?.blockedByMe, + }; + }); + const listedIds = new Set(listed.map((item) => item.id)); + // Watek administracji i szkice nie pochodza z tej listy - zostaja bez zmian. Otwarta + // rozmowa, ktorej nie ma na liscie (ukryta u siebie), zostaje do czasu opuszczenia jej adresu. + const kept = current.filter((item) => !item.conversationId + || (!listedIds.has(item.id) && item.id === routeThreadIdRef.current)); + return [...kept, ...listed]; + }); + setConversationsLoaded(true); + }) + .catch(() => setConversationsLoaded(true)) + ), []); + + useEffect(() => { + if (!user) { + return; + } + void loadConversations(); + const intervalId = window.setInterval(() => { void loadConversations(); }, 30000); + return () => window.clearInterval(intervalId); + }, [user, loadConversations]); + + // Otwarta rozmowa: odswiezanie co 4 s. Pobranie oznacza wiadomosci od rozmowcy jako przeczytane. + useEffect(() => { + if (!user || !conversationParam || conversationParam === ADMIN_CONVERSATION_SLUG) { + return; + } + const id = Number(conversationParam); + if (!Number.isFinite(id) || id <= 0) { + navigate(ROUTES.messages, { replace: true }); + return; + } + let cancelled = false; + const loadDetail = () => { + apiFetch(`/conversations/${id}`) + .then((detail) => { + if (!cancelled) { + applyConversationDetail(detail); + } + }) + .catch((error) => { + const status = (error as { status?: number }).status; + if (!cancelled && (status === 403 || status === 404)) { + setThreads((current) => current.filter((item) => item.id !== `conv-${id}`)); + navigate(ROUTES.messages, { replace: true }); + } + }); + }; + loadDetail(); + const intervalId = window.setInterval(loadDetail, 4000); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [user, conversationParam, applyConversationDetail, navigate]); + + // Szkic rozmowy z ogloszenia. Gdy kupujacy juz pisal w tej sprawie - przechodzimy do tamtej rozmowy. + useEffect(() => { + if (!user || !newListingParam || !conversationsLoaded) { + return; + } + const listingId = Number(newListingParam); + const existing = threads.find((item) => item.conversationId && item.listing?.listingId === listingId && !item.selling); + if (existing?.conversationId) { + navigate(conversationPath(existing.conversationId), { replace: true }); + return; + } + if (threads.some((item) => item.id === `draft-${listingId}`)) { + return; + } + let cancelled = false; + apiFetch(`/listings/${listingId}`) + .then((listing) => { + if (cancelled) { + return; + } + const target = buildMessageThreadTargetFromListing(listing, user); + setThreads((current) => (current.some((item) => item.id === `draft-${listingId}`) ? current : [...current, { + id: `draft-${listingId}`, + draftListingId: listingId, + name: target.name, + avatarImage: target.listing.thumbnail || heroImage, + status: target.status, + contactUserId: target.contactUserId, + listing: target.listing, + messages: [], + }])); + }) + .catch(() => { + if (!cancelled) { + navigate(ROUTES.messages, { replace: true }); + } + }); + return () => { + cancelled = true; + }; + }, [user, newListingParam, conversationsLoaded, threads, navigate]); + useEffect(() => { if (!user || user.role === 'ADMIN') { return; @@ -24296,16 +24540,18 @@ function MessagesPage({ const threadItems = useMemo(() => threads.map((thread) => { const lastMessage = thread.messages[thread.messages.length - 1]; - const unreadCount = thread.messages.filter((message) => !message.mine && message.unread).length; - const waitingReadCount = thread.messages.filter((message) => message.mine && message.readByRecipient === false).length; + // Przed wczytaniem wiadomosci watku korzystamy ze skrotu z listy rozmow. + const lastActivityAt = lastMessage?.sentAt ?? thread.summary?.lastAt ?? 0; + const unreadCount = thread.summary + ? thread.summary.unread + : thread.messages.filter((message) => !message.mine && message.unread).length; return { ...thread, - preview: lastMessage?.text ?? '', - time: lastMessage?.time ?? '', - lastActivityAt: lastMessage?.sentAt ?? 0, + preview: lastMessage?.text ?? thread.summary?.preview ?? '', + time: lastActivityAt > 0 ? chatTimeLabel(new Date(lastActivityAt)) : '', + lastActivityAt, unreadCount, - waitingReadCount, }; }), [threads]); @@ -24327,33 +24573,6 @@ function MessagesPage({ const unreadThreadsCount = threadItems.filter((thread) => thread.unreadCount > 0).length; const readThreadsCount = threadItems.length - unreadThreadsCount; - useEffect(() => { - if (filteredThreads.length === 0) { - return; - } - - const exists = filteredThreads.some((thread) => thread.id === activeThreadId); - if (!exists) { - setActiveThreadId(filteredThreads[0].id); - } - }, [activeThreadId, filteredThreads]); - - // Powiekszone zdjecie zamykamy takze klawiszem Escape - tak samo jak podglad w ogloszeniu. - useEffect(() => { - if (!previewPhoto) { - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setPreviewPhoto(null); - } - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [previewPhoto]); - useEffect(() => { if (!moderationFeedback) { return; @@ -24368,54 +24587,11 @@ function MessagesPage({ }; }, [moderationFeedback]); - useEffect(() => { - if (!initialThreadTarget) { - return; - } + // Bez rozmowy w adresie pokazujemy najnowsza z listy. + const activeThread = routeThreadId + ? threadItems.find((thread) => thread.id === routeThreadId) + : [...threadItems].sort((left, right) => right.lastActivityAt - left.lastActivityAt)[0] as (typeof threadItems)[number] | undefined; - setThreads((current) => { - const exists = current.some((thread) => thread.id === initialThreadTarget.id); - if (exists) { - return current.map((thread) => ( - thread.id === initialThreadTarget.id - ? { - ...thread, - name: initialThreadTarget.name, - status: initialThreadTarget.status, - avatarImage: initialThreadTarget.listing.thumbnail || thread.avatarImage, - contactEmail: initialThreadTarget.contactEmail, - contactUserId: initialThreadTarget.contactUserId, - listing: initialThreadTarget.listing, - } - : thread - )); - } - - return [ - { - id: initialThreadTarget.id, - name: initialThreadTarget.name, - avatarImage: initialThreadTarget.listing.thumbnail || cityImage, - status: initialThreadTarget.status, - contactEmail: initialThreadTarget.contactEmail, - contactUserId: initialThreadTarget.contactUserId, - listing: initialThreadTarget.listing, - messages: [], - }, - ...current, - ]; - }); - - setActiveFilter('all'); - setActiveThreadId(initialThreadTarget.id); - onThreadTargetHandled(); - }, [initialThreadTarget, onThreadTargetHandled]); - - const activeThread = filteredThreads.find((thread) => thread.id === activeThreadId) - ?? threadItems.find((thread) => thread.id === activeThreadId) - ?? threadItems[0] as (typeof threadItems)[number] | undefined; - - // Bez zadnej rozmowy (np. zanim wczyta sie watek z administracja) nie ma czego pokazac. if (!activeThread) { return (
@@ -24429,7 +24605,14 @@ function MessagesPage({

Wiadomości

- Nie masz jeszcze żadnych wiadomości. + {routeThreadId || !conversationsLoaded ? ( + Wczytywanie rozmowy… + ) : ( + <> + Nie masz jeszcze żadnych wiadomości. + Napisz do sprzedającego ze strony ogłoszenia. + + )}
@@ -24445,57 +24628,17 @@ function MessagesPage({ const activeThreadListingSpecs = activeThreadListing ? `${formatArea(activeThreadListing.area)} • ${listingRoomsText(activeThreadListing.rooms)}${activeThreadListingFloor ? ` • ${activeThreadListingFloor} piętro` : ''}` : ''; - const isActiveThreadBlocked = blockedThreadIds.includes(activeThread.id); + const isAdminThread = activeThread.id === ADMIN_THREAD_ID; + const isActiveThreadBlocked = Boolean(activeThread.blocked); const selectedReportReasonDetails = reportReasons.find((reason) => reason.id === selectedReportReason); const isOtherReasonSelected = selectedReportReason === 'other'; const normalizedReportDetails = reportDetails.trim(); const needsMoreDetails = isOtherReasonSelected && normalizedReportDetails.length < 10; const canSubmitReport = !isSubmittingReport && !reportAttachmentError && !needsMoreDetails; - const markIncomingAsRead = (threadId: string) => { - setThreads((current) => current.map((thread) => { - if (thread.id !== threadId) { - return thread; - } - - const hasUnreadIncoming = thread.messages.some((message) => !message.mine && message.unread); - if (!hasUnreadIncoming) { - return thread; - } - - return { - ...thread, - messages: thread.messages.map((message) => ( - message.mine || !message.unread ? message : { ...message, unread: false } - )), - }; - })); - }; - - const markOutgoingAsRead = (threadId: string) => { - setThreads((current) => current.map((thread) => { - if (thread.id !== threadId) { - return thread; - } - - const hasOutgoingPending = thread.messages.some((message) => message.mine && message.readByRecipient === false); - if (!hasOutgoingPending) { - return thread; - } - - return { - ...thread, - messages: thread.messages.map((message) => ( - message.mine ? { ...message, readByRecipient: true } : message - )), - }; - })); - }; - - const openThread = (threadId: string) => { - setActiveThreadId(threadId); - markIncomingAsRead(threadId); - markOutgoingAsRead(threadId); + // Wiadomosci oznacza jako przeczytane serwer, gdy rozmowa zostanie otwarta pod swoim adresem. + const openThread = (thread: ChatThread) => { + navigate(threadPath(thread)); }; const nowTimeLabel = () => { @@ -24531,8 +24674,44 @@ function MessagesPage({ } : thread ))); - } catch { - setModerationFeedback('Nie udało się wysłać wiadomości. Spróbuj ponownie.'); + } catch (error) { + setThreads((current) => current.map((thread) => ( + thread.id === ADMIN_THREAD_ID + ? { ...thread, messages: thread.messages.filter((message) => message.id !== tempId) } + : thread + ))); + setModerationFeedback(error instanceof Error ? error.message : 'Nie udało się wysłać wiadomości. Spróbuj ponownie.'); + } + }; + + // Wysylka do otwartej rozmowy o ogloszeniu. Szkic zaklada rozmowe na serwerze pierwsza wiadomoscia + // i przechodzi pod jej adres. Filtr slow i blokady sprawdza serwer - blad pokazujemy pod polem. + const postToActiveThread = async (content: string) => { + if (isAdminThread) { + await sendAdminMessage(content); + return; + } + setIsSending(true); + try { + if (activeThread.conversationId) { + applyConversationDetail(await apiFetch(`/conversations/${activeThread.conversationId}/messages`, { + method: 'POST', + body: JSON.stringify({ content }), + })); + } else if (activeThread.draftListingId) { + const detail = await apiFetch('/conversations', { + method: 'POST', + body: JSON.stringify({ listingId: activeThread.draftListingId, content }), + }); + applyConversationDetail(detail); + setThreads((current) => current.filter((thread) => thread.id !== activeThread.id)); + navigate(conversationPath(detail.id), { replace: true }); + } + setComposerValue(''); + } catch (error) { + setModerationFeedback(error instanceof Error ? error.message : 'Nie udało się wysłać wiadomości. Spróbuj ponownie.'); + } finally { + setIsSending(false); } }; @@ -24542,195 +24721,20 @@ function MessagesPage({ ? { fullName: user.fullName, email: user.email, phone: user.phone, isCompany: user.accountType === 'COMPANY' } : null; - const onChooseAttachment = () => { - attachmentInputRef.current?.click(); - }; - - // Zdjecie idzie w wiadomosci od razu, bez wstrzymywania na moderacje - naduzycia zalatwia - // blokada rozmowcy i zgloszenie uzytkownika. - const onAttachmentSelected = async (event: ReactChangeEvent) => { - const file = event.target.files?.[0] ?? null; - event.target.value = ''; - setModerationFeedback(''); - - if (!file) { + const sendMessage = () => { + if (isActiveThreadBlocked || isSending) { return; } - - if (!file.type.startsWith('image/')) { - setSelectedAttachment(null); - setModerationFeedback('Możesz dodać tylko plik zdjęciowy.'); - return; - } - - if (file.size > 6 * 1024 * 1024) { - setSelectedAttachment(null); - setModerationFeedback('Maksymalny rozmiar zdjęcia to 6 MB.'); - return; - } - - setSelectedAttachment({ name: file.name, url: await compressImageFile(file, 1024) }); - }; - - const removeSelectedAttachment = () => { - setSelectedAttachment(null); - setModerationFeedback(''); - }; - - const closePhotoPreview = () => { - setPreviewPhoto(null); - }; - - const sendMessage = async () => { - if (isActiveThreadBlocked) { - setModerationFeedback('Ten użytkownik jest zablokowany. Odblokuj go, aby ponownie pisać wiadomości.'); - return; - } - const value = composerValue.trim(); - if (!value && !selectedAttachment) { + if (!value) { return; } - - if (activeThread.id === ADMIN_THREAD_ID) { - void sendAdminMessage(value || 'Wysłano zdjęcie'); - setComposerValue(''); - setSelectedAttachment(null); - return; - } - - // Globalny filtr treści (ta sama lista słów co w panelu admina) sprawdzany po stronie backendu. - if (value) { - try { - await apiFetch('/moderation/check', { method: 'POST', body: JSON.stringify({ text: value }) }); - } catch (err) { - setModerationFeedback(err instanceof Error ? err.message : 'Twoja wiadomość zawiera niedozwolone słownictwo.'); - return; - } - } - - const messageId = `${activeThread.id}-${Date.now()}`; - const sentAt = Date.now(); - const timeLabel = nowTimeLabel(); - const attachment = selectedAttachment; - - setThreads((current) => current.map((thread) => { - if (thread.id !== activeThread.id) { - return thread; - } - - return { - ...thread, - messages: [ - ...thread.messages, - { - id: messageId, - text: value || 'Wysłano zdjęcie', - time: timeLabel, - mine: true, - readByRecipient: false, - sentAt, - attachmentName: attachment?.name, - attachmentUrl: attachment?.url, - }, - ], - }; - })); - - setComposerValue(''); - setSelectedAttachment(null); - - window.setTimeout(() => { - setThreads((current) => current.map((thread) => { - if (thread.id !== activeThread.id) { - return thread; - } - - return { - ...thread, - messages: thread.messages.map((message) => ( - message.id === messageId ? { ...message, readByRecipient: true } : message - )), - }; - })); - }, 4500); - - window.setTimeout(() => { - const receivedAt = Date.now(); - const receivedTimeLabel = nowTimeLabel(); - - setThreads((current) => current.map((thread) => { - if (thread.id !== activeThread.id) { - return thread; - } - - return { - ...thread, - status: `Ostatnio aktywna dziś o ${receivedTimeLabel}`, - messages: [ - ...thread.messages, - { - id: `${activeThread.id}-reply-${receivedAt}`, - text: 'Dziękuję za wiadomość! Odpowiem za chwilę.', - time: receivedTimeLabel, - mine: false, - sentAt: receivedAt, - unread: true, - }, - ], - }; - })); - }, 9000); + void postToActiveThread(value); }; + // Wizytowka to zwykla wiadomosc tekstowa ze znacznikiem - zostaje w historii rozmowy na serwerze. const deliverBusinessCard = (card: BusinessCard) => { - // Rozmowa z administracja jest zapisywana na serwerze - wizytowka idzie ta sama droga - // co zwykla wiadomosc, dzieki czemu zostaje w historii po odswiezeniu strony. - if (activeThread.id === ADMIN_THREAD_ID) { - void sendAdminMessage(formatBusinessCard(card)); - return; - } - - const messageId = `${activeThread.id}-card-${Date.now()}`; - const sentAt = Date.now(); - const timeLabel = nowTimeLabel(); - - setThreads((current) => current.map((thread) => { - if (thread.id !== activeThread.id) { - return thread; - } - - return { - ...thread, - messages: [ - ...thread.messages, - { - id: messageId, - text: BUSINESS_CARD_TEXT, - time: timeLabel, - mine: true, - readByRecipient: false, - sentAt, - businessCard: card, - }, - ], - }; - })); - - window.setTimeout(() => { - setThreads((current) => current.map((thread) => { - if (thread.id !== activeThread.id) { - return thread; - } - - return { - ...thread, - messages: thread.messages.map((message) => ( - message.id === messageId ? { ...message, readByRecipient: true } : message - )), - }; - })); - }, 4500); + void postToActiveThread(formatBusinessCard(card)); }; const sendBusinessCard = () => { @@ -24826,41 +24830,44 @@ function MessagesPage({ setIsDeleteChatModalOpen(false); }; - const confirmToggleBlockUser = () => { - if (isActiveThreadBlocked) { - setBlockedThreadIds((current) => current.filter((id) => id !== activeThread.id)); - setModerationFeedback(`Użytkownik ${activeThread.name} został odblokowany.`); + // Blokada dziala na serwerze w obu kierunkach; odblokowac moze tylko ten, kto zablokowal. + const confirmToggleBlockUser = async () => { + const conversationId = activeThread.conversationId; + if (!conversationId) { setIsBlockModalOpen(false); return; } - - setBlockedThreadIds((current) => ( - current.includes(activeThread.id) ? current : [...current, activeThread.id] - )); - setComposerValue(''); - setSelectedAttachment(null); - setModerationFeedback(`Użytkownik ${activeThread.name} został zablokowany.`); - setIsBlockModalOpen(false); + const unblocking = Boolean(activeThread.blockedByMe); + try { + await apiFetch(`/conversations/${conversationId}/block`, { method: unblocking ? 'DELETE' : 'POST' }); + applyConversationDetail(await apiFetch(`/conversations/${conversationId}`)); + if (!unblocking) { + setComposerValue(''); + } + setModerationFeedback(`Użytkownik ${activeThread.name} został ${unblocking ? 'odblokowany' : 'zablokowany'}.`); + } catch (error) { + setModerationFeedback(error instanceof Error ? error.message : 'Nie udało się zmienić blokady.'); + } finally { + setIsBlockModalOpen(false); + } }; - const confirmDeleteChat = () => { - if (threadItems.length <= 1) { - setModerationFeedback('Nie można usunąć ostatniego czatu.'); - setIsDeleteChatModalOpen(false); - return; - } - + // Rozmowa znika tylko z mojej listy - nowa wiadomosc przywroci ja obu stronom. + const confirmDeleteChat = async () => { const removedThreadName = activeThread.name; - const nextThreadId = threadItems.find((thread) => thread.id !== activeThread.id)?.id; - - setThreads((current) => current.filter((thread) => thread.id !== activeThread.id)); - if (nextThreadId) { - setActiveThreadId(nextThreadId); + try { + if (activeThread.conversationId) { + await apiFetch(`/conversations/${activeThread.conversationId}`, { method: 'DELETE' }); + } + setThreads((current) => current.filter((thread) => thread.id !== activeThread.id)); + setComposerValue(''); + setModerationFeedback(`Czat z użytkownikiem ${removedThreadName} został usunięty.`); + navigate(ROUTES.messages, { replace: true }); + } catch (error) { + setModerationFeedback(error instanceof Error ? error.message : 'Nie udało się usunąć czatu.'); + } finally { + setIsDeleteChatModalOpen(false); } - setComposerValue(''); - setSelectedAttachment(null); - setModerationFeedback(`Czat z użytkownikiem ${removedThreadName} został usunięty.`); - setIsDeleteChatModalOpen(false); }; const openReportModal = () => { @@ -24999,10 +25006,9 @@ function MessagesPage({ method: 'POST', body: JSON.stringify({ reportedName: activeThread.name, - // Konto zgloszonego - tylko rozmowy z realnego ogloszenia je maja. Bez niego panel - // nie pokaze akcji na koncie, bo nie ma czego blokowac ani usuwac. - reportedEmail: activeThread.contactEmail ?? null, - conversationId: activeThread.id, + // Zgloszonego, ogloszenie i tresc rozmowy serwer ustala sam na podstawie numeru rozmowy. + reportedEmail: null, + conversationId: activeThread.conversationId ? String(activeThread.conversationId) : activeThread.id, // Ogloszenie, w sprawie ktorego toczyla sie rozmowa - obsluga widzi kontekst zgloszenia. listingId: activeThreadListing?.listingId ?? null, listingTitle: activeThreadListing?.title ?? null, @@ -25016,7 +25022,7 @@ function MessagesPage({ fromReporter: message.mine, content: message.text, sentAt: new Date(message.sentAt).toISOString(), - hasPhoto: Boolean(message.attachmentUrl), + hasPhoto: false, })), }), }); @@ -25086,7 +25092,7 @@ function MessagesPage({ type="button" key={thread.id} className={thread.id === activeThread.id ? 'active' : ''} - onClick={() => openThread(thread.id)} + onClick={() => openThread(thread)} >