diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java index 5c4a945..85c3b47 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java @@ -106,6 +106,28 @@ public class AccountEmailService { template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link); } + /** + * Potwierdzony termin ogladania. Jedyny e-mail modulu spotkan - propozycje, odmowy + * i odwolania zostaja w serwisie (watek rozmowy i dzwonek). + */ + public void sendMeetingConfirmed(String email, String name, String otherName, String listingTitle, + String when, String location, Long conversationId) { + String link = baseUrl + (conversationId == null ? "/konto/spotkania" : "/wiadomosci/" + conversationId); + String where = location == null || location.isBlank() ? "" : + "

Miejsce: " + HtmlUtils.htmlEscape(location) + "

"; + String about = listingTitle == null || listingTitle.isBlank() ? "" : + "

Ogłoszenie: " + HtmlUtils.htmlEscape(listingTitle) + "

"; + String body = "

Termin oglądania został potwierdzony przez: " + + HtmlUtils.htmlEscape(otherName) + ".

" + + "
" + + "

Termin: " + HtmlUtils.htmlEscape(when) + "

" + + where + about + "
" + + "

Jeśli termin przestanie pasować, odwołaj go w serwisie -" + + " druga strona od razu to zobaczy.

"; + send(email, "Potwierdzone oglądanie: " + when, + template("Termin potwierdzony", greeting(name), body, "Zobacz szczegóły", link), "SPOTKANIE", 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/meeting/Meeting.java b/backend/src/main/java/pl/polskalokalnie/meeting/Meeting.java new file mode 100644 index 0000000..9de7734 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/meeting/Meeting.java @@ -0,0 +1,222 @@ +package pl.polskalokalnie.meeting; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; + +/** + * Spotkanie (ogladanie nieruchomosci) albo wlasny wpis w kalendarzu konta. Wczesniej kalendarz + * zyl w localStorage jednej przegladarki - druga strona nic o nim nie wiedziala. + * + *

Wpis prywatny ma puste conversationId i counterpartId oraz status CONFIRMED - nikt poza + * autorem go nie widzi. Spotkanie umowione z druga strona powstaje z rozmowy o ogloszeniu + * i jest widoczne dla obu uczestnikow.

+ */ +@Entity +@Table(name = "meetings") +public class Meeting { + + public enum Status { + /** Termin zaproponowany, czeka na odpowiedz drugiej strony. */ + PROPOSED, + /** Termin potwierdzony przez druga strone. */ + CONFIRMED, + /** Druga strona odrzucila termin. */ + DECLINED, + /** Odwolane - moze to zrobic kazda ze stron, do konca spotkania. */ + CANCELLED + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "conversation_id") + private Long conversationId; + + @Column(name = "listing_id") + private Long listingId; + + @Column(name = "listing_title", length = 200) + private String listingTitle; + + /** Kto zaproponowal termin (albo autor wpisu prywatnego). */ + @Column(name = "proposer_id", nullable = false) + private Long proposerId; + + /** Druga strona spotkania; null dla wpisu prywatnego. */ + @Column(name = "counterpart_id") + private Long counterpartId; + + @Column(nullable = false, length = 160) + private String title; + + @Column(length = 200) + private String note; + + @Column(length = 200) + private String location; + + @Column(name = "meeting_date", nullable = false) + private LocalDate meetingDate; + + @Column(name = "start_time", nullable = false) + private LocalTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalTime endTime; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status = Status.PROPOSED; + + /** Kto odwolal spotkanie - druga strona widzi, po czyjej stronie byla zmiana. */ + @Column(name = "cancelled_by_id") + private Long cancelledById; + + @Column(name = "created_at", nullable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + public boolean isPrivateNote() { + return counterpartId == null; + } + + public boolean isParticipant(Long userId) { + return proposerId.equals(userId) || (counterpartId != null && counterpartId.equals(userId)); + } + + public Long otherParticipant(Long userId) { + return proposerId.equals(userId) ? counterpartId : proposerId; + } + + public Long getId() { + return id; + } + + public Long getConversationId() { + return conversationId; + } + + public void setConversationId(Long conversationId) { + this.conversationId = conversationId; + } + + public Long getListingId() { + return listingId; + } + + public void setListingId(Long listingId) { + this.listingId = listingId; + } + + public String getListingTitle() { + return listingTitle; + } + + public void setListingTitle(String listingTitle) { + this.listingTitle = listingTitle; + } + + public Long getProposerId() { + return proposerId; + } + + public void setProposerId(Long proposerId) { + this.proposerId = proposerId; + } + + public Long getCounterpartId() { + return counterpartId; + } + + public void setCounterpartId(Long counterpartId) { + this.counterpartId = counterpartId; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public LocalDate getMeetingDate() { + return meetingDate; + } + + public void setMeetingDate(LocalDate meetingDate) { + this.meetingDate = meetingDate; + } + + public LocalTime getStartTime() { + return startTime; + } + + public void setStartTime(LocalTime startTime) { + this.startTime = startTime; + } + + public LocalTime getEndTime() { + return endTime; + } + + public void setEndTime(LocalTime endTime) { + this.endTime = endTime; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public Long getCancelledById() { + return cancelledById; + } + + public void setCancelledById(Long cancelledById) { + this.cancelledById = cancelledById; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java new file mode 100644 index 0000000..0b38898 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java @@ -0,0 +1,69 @@ +package pl.polskalokalnie.meeting; + +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest; +import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; + +/** Kalendarz spotkan zalogowanego uzytkownika - wlasne wpisy i ogladania umowione z druga strona. */ +@RestController +@RequestMapping("/api/meetings") +public class MeetingController { + + private final MeetingService meetingService; + private final UserRepository userRepository; + + public MeetingController(MeetingService meetingService, UserRepository userRepository) { + this.meetingService = meetingService; + this.userRepository = userRepository; + } + + @GetMapping + public List list(Authentication authentication) { + return meetingService.list(me(authentication)); + } + + @PostMapping + public MeetingResponse create(@RequestBody MeetingRequest request, Authentication authentication) { + return meetingService.create(me(authentication), request); + } + + @PutMapping("/{id}") + public MeetingResponse update(@PathVariable Long id, @RequestBody MeetingRequest request, Authentication authentication) { + return meetingService.updatePrivate(me(authentication), id, request); + } + + @PostMapping("/{id}/confirm") + public MeetingResponse confirm(@PathVariable Long id, Authentication authentication) { + return meetingService.respond(me(authentication), id, true); + } + + @PostMapping("/{id}/decline") + public MeetingResponse decline(@PathVariable Long id, Authentication authentication) { + return meetingService.respond(me(authentication), id, false); + } + + @DeleteMapping("/{id}") + public ResponseEntity cancel(@PathVariable Long id, Authentication authentication) { + meetingService.cancel(me(authentication), id); + return ResponseEntity.noContent().build(); + } + + private AppUser me(Authentication authentication) { + return userRepository.findByEmailIgnoreCase(authentication.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Zaloguj się ponownie")); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java new file mode 100644 index 0000000..1d20321 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java @@ -0,0 +1,29 @@ +package pl.polskalokalnie.meeting; + +import java.time.LocalDate; +import java.time.LocalTime; + +public final class MeetingDtos { + + private MeetingDtos() { + } + + /** + * Propozycja terminu albo wlasny wpis w kalendarzu. Z conversationId powstaje spotkanie + * z druga strona rozmowy; bez niego - wpis prywatny, widoczny tylko dla autora. + */ + public record MeetingRequest(Long conversationId, String title, String note, String location, + LocalDate date, LocalTime start, LocalTime end) { + } + + /** + * status: PROPOSED / CONFIRMED / DECLINED / CANCELLED. + * proposedByMe - czy to ja zaproponowalem termin (tylko druga strona moze go potwierdzic). + * privateNote - wpis wlasny, bez drugiej strony. + */ + public record MeetingResponse(Long id, Long conversationId, Long listingId, String listingTitle, + String title, String note, String location, LocalDate date, LocalTime start, + LocalTime end, String status, boolean proposedByMe, boolean privateNote, + String otherName, boolean cancelledByMe) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java new file mode 100644 index 0000000..cbb9fdd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java @@ -0,0 +1,13 @@ +package pl.polskalokalnie.meeting; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MeetingRepository extends JpaRepository { + + @Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId" + + " order by m.meetingDate asc, m.startTime asc") + List findForUser(@Param("userId") Long userId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java new file mode 100644 index 0000000..7cdbb51 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java @@ -0,0 +1,270 @@ +package pl.polskalokalnie.meeting; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +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.auth.AccountEmailService; +import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest; +import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse; +import pl.polskalokalnie.message.Conversation; +import pl.polskalokalnie.message.ConversationRepository; +import pl.polskalokalnie.message.ConversationService; +import pl.polskalokalnie.notification.NotificationService; +import pl.polskalokalnie.user.AppUser; +import pl.polskalokalnie.user.UserRepository; +import pl.polskalokalnie.user.UserSettings; +import pl.polskalokalnie.user.UserSettingsRepository; + +/** + * Umawianie ogladania nieruchomosci. Termin proponuje jedna ze stron rozmowy o ogloszeniu, + * druga potwierdza albo odrzuca; odwolac moze kazda ze stron, do konca spotkania. + * + *

Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail + * wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.

+ */ +@Service +public class MeetingService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm"); + + private final MeetingRepository meetingRepository; + private final ConversationRepository conversationRepository; + private final ConversationService conversationService; + private final UserRepository userRepository; + private final UserSettingsRepository userSettingsRepository; + private final NotificationService notificationService; + private final AccountEmailService accountEmailService; + + public MeetingService(MeetingRepository meetingRepository, + ConversationRepository conversationRepository, + ConversationService conversationService, + UserRepository userRepository, + UserSettingsRepository userSettingsRepository, + NotificationService notificationService, + AccountEmailService accountEmailService) { + this.meetingRepository = meetingRepository; + this.conversationRepository = conversationRepository; + this.conversationService = conversationService; + this.userRepository = userRepository; + this.userSettingsRepository = userSettingsRepository; + this.notificationService = notificationService; + this.accountEmailService = accountEmailService; + } + + @Transactional(readOnly = true) + public List list(AppUser me) { + List meetings = meetingRepository.findForUser(me.getId()); + Map others = userRepository.findAllById(meetings.stream() + .map(meeting -> meeting.otherParticipant(me.getId())) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet())) + .stream().collect(Collectors.toMap(AppUser::getId, Function.identity())); + return meetings.stream().map(meeting -> toResponse(meeting, me, others.get(meeting.otherParticipant(me.getId())))).toList(); + } + + @Transactional + public MeetingResponse create(AppUser me, MeetingRequest request) { + validate(request); + Meeting meeting = new Meeting(); + meeting.setProposerId(me.getId()); + meeting.setTitle(request.title().trim()); + meeting.setNote(trimOrNull(request.note())); + meeting.setLocation(trimOrNull(request.location())); + meeting.setMeetingDate(request.date()); + meeting.setStartTime(request.start()); + meeting.setEndTime(request.end()); + + if (request.conversationId() == null) { + // Wpis wlasny - nie ma z kim uzgadniac terminu, wiec od razu jest potwierdzony. + meeting.setStatus(Meeting.Status.CONFIRMED); + return toResponse(meetingRepository.save(meeting), me, null); + } + + Conversation conversation = requireConversation(me, request.conversationId()); + AppUser other = requireUser(conversation.otherParticipant(me.getId())); + meeting.setConversationId(conversation.getId()); + meeting.setListingId(conversation.getListingId()); + meeting.setListingTitle(conversation.getListingTitle()); + meeting.setCounterpartId(other.getId()); + meeting.setStatus(Meeting.Status.PROPOSED); + Meeting saved = meetingRepository.save(meeting); + + String when = when(saved); + conversationService.appendMeetingNote(conversation, me, + "Propozycja oglądania: " + when + (saved.getLocation() == null ? "" : ", " + saved.getLocation())); + notify(other, "Propozycja oglądania", displayName(me) + " proponuje termin: " + when, conversation.getId()); + return toResponse(saved, me, other); + } + + /** Zmiana wlasnego wpisu w kalendarzu. Terminu umowionego z druga strona sie nie edytuje - odwoluje sie go i proponuje nowy. */ + @Transactional + public MeetingResponse updatePrivate(AppUser me, Long meetingId, MeetingRequest request) { + validate(request); + Meeting meeting = requireParticipant(me, meetingId); + if (!meeting.isPrivateNote()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Umówionego terminu nie da się zmienić - odwołaj go i zaproponuj nowy"); + } + meeting.setTitle(request.title().trim()); + meeting.setNote(trimOrNull(request.note())); + meeting.setLocation(trimOrNull(request.location())); + meeting.setMeetingDate(request.date()); + meeting.setStartTime(request.start()); + meeting.setEndTime(request.end()); + meeting.setUpdatedAt(Instant.now()); + return toResponse(meeting, me, null); + } + + /** Potwierdzenie albo odrzucenie terminu - tylko przez osobe, ktora go dostala. */ + @Transactional + public MeetingResponse respond(AppUser me, Long meetingId, boolean accept) { + Meeting meeting = requireParticipant(me, meetingId); + if (meeting.isPrivateNote() || meeting.getProposerId().equals(me.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Termin potwierdza osoba, która go otrzymała"); + } + if (meeting.getStatus() != Meeting.Status.PROPOSED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten termin został już rozpatrzony"); + } + meeting.setStatus(accept ? Meeting.Status.CONFIRMED : Meeting.Status.DECLINED); + meeting.setUpdatedAt(Instant.now()); + + AppUser other = requireUser(meeting.getProposerId()); + String when = when(meeting); + Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null); + if (conversation != null) { + conversationService.appendMeetingNote(conversation, me, + (accept ? "Potwierdzam oglądanie: " : "Nie pasuje mi termin: ") + when); + } + notify(other, accept ? "Termin potwierdzony" : "Termin odrzucony", + displayName(me) + (accept ? " potwierdza oglądanie: " : " nie może w terminie: ") + when, + meeting.getConversationId()); + if (accept) { + sendConfirmationEmail(other, me, meeting); + sendConfirmationEmail(me, other, meeting); + } + return toResponse(meeting, me, other); + } + + /** Odwolac moze kazda ze stron, do konca spotkania. Wpis wlasny po prostu znika. */ + @Transactional + public void cancel(AppUser me, Long meetingId) { + Meeting meeting = requireParticipant(me, meetingId); + if (meeting.isPrivateNote()) { + meetingRepository.delete(meeting); + return; + } + if (meeting.getStatus() == Meeting.Status.CANCELLED) { + return; + } + meeting.setStatus(Meeting.Status.CANCELLED); + meeting.setCancelledById(me.getId()); + meeting.setUpdatedAt(Instant.now()); + + AppUser other = requireUser(meeting.otherParticipant(me.getId())); + String when = when(meeting); + Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null); + if (conversation != null) { + conversationService.appendMeetingNote(conversation, me, "Odwołanie oglądania: " + when); + } + notify(other, "Oglądanie odwołane", displayName(me) + " odwołuje termin: " + when, meeting.getConversationId()); + } + + private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) { + boolean wantsEmail = userSettingsRepository.findByUserId(recipient.getId()) + .map(UserSettings::isNotifyMessages) + .orElse(true); + if (!wantsEmail) { + return; + } + accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other), + meeting.getListingTitle(), when(meeting), meeting.getLocation(), meeting.getConversationId()); + } + + private void notify(AppUser recipient, String title, String body, Long conversationId) { + notificationService.create(recipient.getEmail(), "messages", "calendar", title, body, + conversationId == null ? "meetings" : "messages:conversation:" + conversationId, null); + } + + private String when(Meeting meeting) { + return meeting.getMeetingDate().format(DATE) + ", " + meeting.getStartTime().format(TIME) + + "-" + meeting.getEndTime().format(TIME); + } + + private void validate(MeetingRequest request) { + if (request == null || request.title() == null || request.title().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj nazwę spotkania"); + } + if (request.date() == null || request.start() == null || request.end() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj datę i godziny spotkania"); + } + if (!request.end().isAfter(request.start())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Godzina zakończenia musi być późniejsza niż rozpoczęcia"); + } + if (request.date().isBefore(LocalDate.now())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości"); + } + } + + private Conversation requireConversation(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 Meeting requireParticipant(AppUser me, Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono spotkania")); + if (!meeting.isParticipant(me.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tego spotkania"); + } + return meeting; + } + + private AppUser requireUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto drugiej strony zostało usunięte")); + } + + private MeetingResponse toResponse(Meeting meeting, AppUser me, AppUser other) { + return new MeetingResponse( + meeting.getId(), + meeting.getConversationId(), + meeting.getListingId(), + meeting.getListingTitle(), + meeting.getTitle(), + meeting.getNote(), + meeting.getLocation(), + meeting.getMeetingDate(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getStatus().name(), + meeting.getProposerId().equals(me.getId()), + meeting.isPrivateNote(), + meeting.isPrivateNote() ? null : displayName(other), + meeting.getCancelledById() != null && meeting.getCancelledById().equals(me.getId())); + } + + private static String displayName(AppUser user) { + if (user == null) { + return "Konto usunięte"; + } + String name = user.getFullName(); + return name == null || name.isBlank() ? user.getEmail() : name; + } + + private static String trimOrNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java index 3c7e637..ffe87ff 100644 --- a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java +++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java @@ -167,6 +167,24 @@ public class ConversationService { return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId()); } + /** + * Wpis o spotkaniu w watku rozmowy. Swiadomie NIE publikuje zdarzenia o nowej wiadomosci: + * o terminach powiadamia modul spotkan (dzwonek zawsze, e-mail tylko przy potwierdzeniu), + * wiec kazda propozycja nie ma prawa wygenerowac osobnego maila. + */ + @Transactional + public void appendMeetingNote(Conversation conversation, AppUser sender, String content) { + Message message = new Message(); + message.setConversationId(conversation.getId()); + message.setSenderId(sender.getId()); + message.setRecipientId(conversation.otherParticipant(sender.getId())); + message.setContent(content.trim()); + messageRepository.save(message); + + conversation.setLastMessageAt(Instant.now()); + conversation.restoreForBoth(); + } + private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) { textModerationService.validateOrThrow(content); String trimmed = content.trim(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad46201..bc70288 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -271,14 +271,6 @@ const LIVE_LISTINGS_REFRESH_MS = 15000; type NotificationCategory = 'listing' | 'messages' | 'system'; -const meetingsStorageKey = 'polskalokalnie-account-meetings'; -// Identyfikatory przykladowych spotkan, ktore wczesniejsza wersja zapisywala kazdemu w localStorage. -// Odfiltrowujemy je przy odczycie, zeby zniknely takze u osob, ktore juz je maja zapisane. -const LEGACY_DEMO_MEETING_IDS = new Set([ - 'meeting-1', 'meeting-2', 'meeting-3', 'meeting-4', - 'meeting-test-now-1', 'meeting-test-week-ago-1', 'meeting-test-prev-month-1', -]); - type FavoriteTab = 'buy' | 'rent'; type FavoriteListing = { @@ -6472,24 +6464,26 @@ function AccountSidebar() { const messagesUnreadCount = useMessagesUnreadCount(); // Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty. const [allListingsCount, setAllListingsCount] = useState(0); - // Spotkania zaplanowane na dzisiaj (odwolane sie nie licza). - const todayMeetingsCount = (() => { - if (typeof window === 'undefined') { - return 0; + // Spotkania na dzisiaj (odwolane i odrzucone sie nie licza) - z konta, nie z przegladarki. + const [todayMeetingsCount, setTodayMeetingsCount] = useState(0); + useEffect(() => { + if (!user) { + setTodayMeetingsCount(0); + return; } - try { - const raw = window.localStorage.getItem(meetingsStorageKey); - const parsed = raw ? JSON.parse(raw) : []; - if (!Array.isArray(parsed)) { - return 0; - } - const now = new Date(); - const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - return parsed.filter((item) => item && !LEGACY_DEMO_MEETING_IDS.has(item.id) && item.dateKey === todayKey && item.status !== 'cancelled').length; - } catch { - return 0; - } - })(); + let cancelled = false; + const now = new Date(); + const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + apiFetch('/meetings') + .then((items) => { + if (!cancelled) { + setTodayMeetingsCount(items.filter((item) => item.date === todayKey + && item.status !== 'CANCELLED' && item.status !== 'DECLINED').length); + } + }) + .catch(() => { if (!cancelled) { setTodayMeetingsCount(0); } }); + return () => { cancelled = true; }; + }, [user, location.pathname]); // Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert. const [savedSearchCount, setSavedSearchCount] = useState(0); useEffect(() => { @@ -9095,22 +9089,54 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number) ); } -function AccountMeetingsPage() { - const { notify } = useNotifications(); - type MeetingType = 'buy' | 'rent' | 'own'; - type MeetingStatus = 'planned' | 'completed' | 'cancelled'; - type MeetingItem = { - id: string; - title: string; - subtitle: string; - location: string; - dateKey: string; - start: string; - end: string; - type: MeetingType; - status: MeetingStatus; - }; +// Spotkanie z API: ogladanie umowione z druga strona rozmowy albo wlasny wpis w kalendarzu. +type ApiMeeting = { + id: number; + conversationId: number | null; + listingId: number | null; + listingTitle: string | null; + title: string; + note: string | null; + location: string | null; + date: string; + start: string; + end: string; + status: 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED'; + proposedByMe: boolean; + privateNote: boolean; + otherName: string | null; + cancelledByMe: boolean; +}; +// Serwer podaje godziny jako HH:mm:ss - w kalendarzu i polach formularza uzywamy HH:mm. +function meetingTime(value: string): string { + return value.slice(0, 5); +} + +function meetingStatusLabel(meeting: ApiMeeting): string { + if (meeting.status === 'CONFIRMED') { + return meeting.privateNote ? 'Wpis własny' : 'Potwierdzone'; + } + if (meeting.status === 'DECLINED') { + return 'Termin odrzucony'; + } + if (meeting.status === 'CANCELLED') { + return meeting.cancelledByMe ? 'Odwołane przez Ciebie' : 'Odwołane'; + } + return meeting.proposedByMe ? 'Czeka na potwierdzenie' : 'Do potwierdzenia'; +} + +function meetingStatusClass(meeting: ApiMeeting): string { + if (meeting.status === 'CONFIRMED') { + return 'status-completed'; + } + if (meeting.status === 'DECLINED' || meeting.status === 'CANCELLED') { + return 'status-cancelled'; + } + return 'status-planned'; +} + +function AccountMeetingsPage() { const formatDateKey = (value: Date) => { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, '0'); @@ -9118,15 +9144,16 @@ function AccountMeetingsPage() { return `${year}-${month}-${day}`; }; - const storageKey = 'polskalokalnie-account-meetings'; const [calendarMonth, setCalendarMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const [selectedDateKey, setSelectedDateKey] = useState(() => formatDateKey(new Date())); - const [showBuyMeetings, setShowBuyMeetings] = useState(true); - const [showRentMeetings, setShowRentMeetings] = useState(true); + const [showSharedMeetings, setShowSharedMeetings] = useState(true); const [showOwnMeetings, setShowOwnMeetings] = useState(true); const [isCreateFormOpen, setIsCreateFormOpen] = useState(false); - const [editingMeetingId, setEditingMeetingId] = useState(null); + const [editingMeetingId, setEditingMeetingId] = useState(null); const [formError, setFormError] = useState(''); + const [busy, setBusy] = useState(false); + const [meetings, setMeetings] = useState([]); + const [loading, setLoading] = useState(true); const [newMeeting, setNewMeeting] = useState({ title: '', subtitle: '', @@ -9134,41 +9161,23 @@ function AccountMeetingsPage() { dateKey: formatDateKey(new Date()), start: '10:00', end: '10:30', - type: 'buy' as MeetingType, - }); - const [meetings, setMeetings] = useState(() => { - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - return parsed - .filter((item): item is MeetingItem => ( - item - && typeof item.id === 'string' - && !LEGACY_DEMO_MEETING_IDS.has(item.id) - && typeof item.title === 'string' - && typeof item.subtitle === 'string' - && typeof item.location === 'string' - && typeof item.dateKey === 'string' - && typeof item.start === 'string' - && typeof item.end === 'string' - && (item.type === 'buy' || item.type === 'rent' || item.type === 'own') - && (item.status === 'planned' || item.status === 'completed' || item.status === 'cancelled') - )) - .sort((left, right) => `${left.dateKey}-${left.start}`.localeCompare(`${right.dateKey}-${right.start}`)); - } catch { - return []; - } }); + const reload = useCallback(() => ( + apiFetch('/meetings') + .then((items) => { setMeetings(items); return items; }) + .catch(() => { setMeetings([]); return [] as ApiMeeting[]; }) + .finally(() => setLoading(false)) + ), []); + useEffect(() => { - window.localStorage.setItem(storageKey, JSON.stringify(meetings)); - }, [meetings]); + let cancelled = false; + apiFetch('/meetings') + .then((items) => { if (!cancelled) { setMeetings(items); } }) + .catch(() => { if (!cancelled) { setMeetings([]); } }) + .finally(() => { if (!cancelled) { setLoading(false); } }); + return () => { cancelled = true; }; + }, []); const monthLabel = calendarMonth.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' }); const todayKey = formatDateKey(new Date()); @@ -9182,18 +9191,15 @@ function AccountMeetingsPage() { }); const visibleSelectedMeetings = meetings - .filter((meeting) => meeting.dateKey === selectedDateKey) - .filter((meeting) => ( - (meeting.type === 'buy' && showBuyMeetings) - || (meeting.type === 'rent' && showRentMeetings) - || (meeting.type === 'own' && showOwnMeetings) - )) + .filter((meeting) => meeting.date === selectedDateKey) + .filter((meeting) => (meeting.privateNote ? showOwnMeetings : showSharedMeetings)) .sort((left, right) => left.start.localeCompare(right.start)); + const selectedDayMeetings = meetings.filter((meeting) => meeting.date === selectedDateKey); const selectedDayTotals = { - all: meetings.filter((meeting) => meeting.dateKey === selectedDateKey).length, - planned: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'planned').length, - completed: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'completed').length, + all: selectedDayMeetings.length, + waiting: selectedDayMeetings.filter((meeting) => meeting.status === 'PROPOSED').length, + confirmed: selectedDayMeetings.filter((meeting) => meeting.status === 'CONFIRMED').length, }; const selectedDateLabel = (() => { @@ -9206,15 +9212,7 @@ function AccountMeetingsPage() { }; const resetFormForDate = (dateKey: string) => { - setNewMeeting({ - title: '', - subtitle: '', - location: '', - dateKey, - start: '10:00', - end: '10:30', - type: 'buy', - }); + setNewMeeting({ title: '', subtitle: '', location: '', dateKey, start: '10:00', end: '10:30' }); setEditingMeetingId(null); setFormError(''); }; @@ -9236,65 +9234,64 @@ function AccountMeetingsPage() { return (hours * 60) + minutes; }; - const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId?: string | null) => { + // Odwolane i odrzucone terminy nie blokuja godziny - miejsce w kalendarzu jest znowu wolne. + const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId: number | null) => { const candidateStartMinutes = toMinutes(candidateStart); const candidateEndMinutes = toMinutes(candidateEnd); return meetings.some((meeting) => { - if (meeting.dateKey !== candidateDateKey || meeting.id === ignoreId) { + if (meeting.date !== candidateDateKey || meeting.id === ignoreId) { return false; } - const startMinutes = toMinutes(meeting.start); - const endMinutes = toMinutes(meeting.end); - return candidateStartMinutes < endMinutes && candidateEndMinutes > startMinutes; + if (meeting.status === 'CANCELLED' || meeting.status === 'DECLINED') { + return false; + } + return candidateStartMinutes < toMinutes(meetingTime(meeting.end)) + && candidateEndMinutes > toMinutes(meetingTime(meeting.start)); }); }; - const setStatus = (id: string, status: MeetingStatus) => { - if (selectedDateIsPast) { - return; - } - const target = meetings.find((meeting) => meeting.id === id); - setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting))); - if (target && status === 'cancelled') { - notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` }); - } else if (target && status === 'completed') { - notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` }); - } + const run = (action: Promise, onError: string) => { + setBusy(true); + action + .then(() => reload()) + .catch((error: Error) => setFormError(error.message || onError)) + .finally(() => setBusy(false)); }; - const removeMeeting = (id: string) => { - if (selectedDateIsPast) { - return; - } - setMeetings((current) => current.filter((meeting) => meeting.id !== id)); - if (editingMeetingId === id) { + const confirmMeeting = (meeting: ApiMeeting) => { + run(apiFetch(`/meetings/${meeting.id}/confirm`, { method: 'POST' }), 'Nie udało się potwierdzić terminu.'); + }; + + const declineMeeting = (meeting: ApiMeeting) => { + run(apiFetch(`/meetings/${meeting.id}/decline`, { method: 'POST' }), 'Nie udało się odrzucić terminu.'); + }; + + const cancelMeeting = (meeting: ApiMeeting) => { + run(apiFetch(`/meetings/${meeting.id}`, { method: 'DELETE' }), 'Nie udało się odwołać spotkania.'); + if (editingMeetingId === meeting.id) { setEditingMeetingId(null); setIsCreateFormOpen(false); } }; - const openEditMeeting = (meeting: MeetingItem) => { - if (isPastDateKey(meeting.dateKey)) { - return; - } + const openEditMeeting = (meeting: ApiMeeting) => { setEditingMeetingId(meeting.id); setNewMeeting({ title: meeting.title, - subtitle: meeting.subtitle, - location: meeting.location, - dateKey: meeting.dateKey, - start: meeting.start, - end: meeting.end, - type: meeting.type, + subtitle: meeting.note ?? '', + location: meeting.location ?? '', + dateKey: meeting.date, + start: meetingTime(meeting.start), + end: meetingTime(meeting.end), }); setFormError(''); - setSelectedDateKey(meeting.dateKey); - const selectedDate = new Date(meeting.dateKey); + setSelectedDateKey(meeting.date); + const selectedDate = new Date(meeting.date); setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); setIsCreateFormOpen(true); }; - const addMeeting = () => { + const saveMeeting = () => { if (isPastDateKey(newMeeting.dateKey)) { setFormError('Nie można dodawać ani edytować spotkań wstecz.'); return; @@ -9312,66 +9309,31 @@ function AccountMeetingsPage() { return; } - if (editingMeetingId) { - setMeetings((current) => current.map((meeting) => (meeting.id === editingMeetingId - ? { - ...meeting, - title: newMeeting.title.trim(), - subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane', - location: newMeeting.location.trim() || 'Do uzupełnienia', - dateKey: newMeeting.dateKey, - start: newMeeting.start, - end: newMeeting.end, - type: newMeeting.type, - } - : meeting))); - } else { - setMeetings((current) => [ - ...current, - { - id: `meeting-${Date.now()}`, - title: newMeeting.title.trim(), - subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane', - location: newMeeting.location.trim() || 'Do uzupełnienia', - dateKey: newMeeting.dateKey, - start: newMeeting.start, - end: newMeeting.end, - type: newMeeting.type, - status: 'planned', - }, - ]); - } + const payload = { + title: newMeeting.title.trim(), + note: newMeeting.subtitle.trim(), + location: newMeeting.location.trim(), + date: newMeeting.dateKey, + start: newMeeting.start, + end: newMeeting.end, + }; + const request = editingMeetingId + ? apiFetch(`/meetings/${editingMeetingId}`, { method: 'PUT', body: JSON.stringify(payload) }) + : apiFetch('/meetings', { method: 'POST', body: JSON.stringify(payload) }); - if (!editingMeetingId) { - notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” - ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' }); - } - - setFormError(''); - setSelectedDateKey(newMeeting.dateKey); - const selectedDate = new Date(newMeeting.dateKey); - setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); - resetFormForDate(newMeeting.dateKey); - setIsCreateFormOpen(false); - }; - - const markerClass = (type: MeetingType) => { - if (type === 'buy') { - return 'buy'; - } - if (type === 'rent') { - return 'rent'; - } - return 'own'; - }; - - const statusLabel = (status: MeetingStatus) => { - if (status === 'completed') { - return 'Zrealizowane'; - } - if (status === 'cancelled') { - return 'Odwołane'; - } - return 'Zaplanowane'; + setBusy(true); + request + .then(() => reload()) + .then(() => { + setFormError(''); + setSelectedDateKey(newMeeting.dateKey); + const selectedDate = new Date(newMeeting.dateKey); + setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + resetFormForDate(newMeeting.dateKey); + setIsCreateFormOpen(false); + }) + .catch((error: Error) => setFormError(error.message || 'Nie udało się zapisać spotkania.')) + .finally(() => setBusy(false)); }; useEffect(() => { @@ -9392,7 +9354,7 @@ function AccountMeetingsPage() {

Umówione spotkania

-

Zarządzaj swoimi spotkaniami i przeglądaj nadchodzące terminy.

+

Oglądania umówione w rozmowach o ogłoszeniach oraz Twoje własne wpisy w kalendarzu.

@@ -9408,8 +9370,7 @@ function AccountMeetingsPage() { className="meetings-today-button" onClick={() => { const today = new Date(); - const todayKey = formatDateKey(today); - setSelectedDateKey(todayKey); + setSelectedDateKey(formatDateKey(today)); setCalendarMonth(new Date(today.getFullYear(), today.getMonth(), 1)); }} > @@ -9426,7 +9387,7 @@ function AccountMeetingsPage() { const isOutside = date.getMonth() !== calendarMonth.getMonth(); const isActive = key === selectedDateKey; const isPast = isPastDateKey(key); - const hasMeeting = meetings.some((meeting) => meeting.dateKey === key); + const hasMeeting = meetings.some((meeting) => meeting.date === key && meeting.status !== 'CANCELLED'); return (

Filtry

-
+ +

+ Termin oglądania proponujesz w rozmowie o ogłoszeniu - przejdź do wiadomości. +

@@ -9468,8 +9431,8 @@ function AccountMeetingsPage() {

{selectedDateLabel}

Wszystkie: {selectedDayTotals.all} - Zaplanowane: {selectedDayTotals.planned} - Zrealizowane: {selectedDayTotals.completed} + Do potwierdzenia: {selectedDayTotals.waiting} + Potwierdzone: {selectedDayTotals.confirmed}
{selectedDateIsPast && (

Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.

@@ -9477,50 +9440,54 @@ function AccountMeetingsPage() {
- {visibleSelectedMeetings.length === 0 &&

Brak spotkań dla wybranego dnia.

} - {visibleSelectedMeetings.map((meeting) => ( -
-
{meeting.start}- {meeting.end}
-
- {meeting.title} -

{meeting.subtitle}

- {meeting.location} -
-
- {statusLabel(meeting.status)} - {!selectedDateIsPast && ( -
- {meeting.status !== 'completed' && ( - - )} - {meeting.status !== 'planned' && ( - - )} - - - -
- )} -
-
- ))} + {loading &&

Wczytujemy kalendarz…

} + {!loading && visibleSelectedMeetings.length === 0 &&

Brak spotkań dla wybranego dnia.

} + {visibleSelectedMeetings.map((meeting) => { + const canDecide = !meeting.privateNote && meeting.status === 'PROPOSED' && !meeting.proposedByMe; + const canCancel = meeting.status === 'PROPOSED' || meeting.status === 'CONFIRMED'; + return ( +
+
{meetingTime(meeting.start)}- {meetingTime(meeting.end)}
+
+ {meeting.title} +

{meeting.privateNote ? (meeting.note || 'Wpis własny') : `Z: ${meeting.otherName ?? 'druga strona'}${meeting.listingTitle ? ` · ${meeting.listingTitle}` : ''}`}

+ {meeting.location || 'Miejsce do uzgodnienia'} +
+
+ {meetingStatusLabel(meeting)} + {!selectedDateIsPast && ( +
+ {canDecide && ( + + )} + {canDecide && ( + + )} + {meeting.privateNote && ( + + )} + {meeting.conversationId != null && ( + + )} + {canCancel && ( + + )} +
+ )} +
+
+ ); + })}
{isCreateFormOpen && (
-

{editingMeetingId ? 'Edytuj spotkanie' : 'Dodaj spotkanie ręcznie'}

+

{editingMeetingId ? 'Edytuj wpis' : 'Dodaj własny wpis'}

- - - + + + -
@@ -9536,10 +9503,11 @@ function AccountMeetingsPage() { > Anuluj - +
)} + {!isCreateFormOpen && formError &&

{formError}

}
@@ -24927,6 +24895,13 @@ function MessagesPage({ const [activeFilter, setActiveFilter] = useState('all'); const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [composerValue, setComposerValue] = useState(''); + // Propozycja ogladania z poziomu rozmowy - termin trafia do kalendarza obu stron. + const meetingToday = new Date().toISOString().slice(0, 10); + const [meetingFormOpen, setMeetingFormOpen] = useState(false); + const [meetingForm, setMeetingForm] = useState({ date: meetingToday, start: '17:00', end: '17:30', location: '' }); + const [meetingFeedback, setMeetingFeedback] = useState(''); + const [meetingError, setMeetingError] = useState(''); + const [meetingBusy, setMeetingBusy] = useState(false); const [isSending, setIsSending] = useState(false); const [moderationFeedback, setModerationFeedback] = useState(''); const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); @@ -25364,6 +25339,39 @@ function MessagesPage({ void postToActiveThread(value); }; + // Propozycja terminu: zapisuje sie w kalendarzu obu stron i zostawia wpis w watku rozmowy. + const proposeMeeting = () => { + const conversationId = activeThread.conversationId; + if (!conversationId || meetingBusy) { + return; + } + if (meetingForm.end <= meetingForm.start) { + setMeetingError('Godzina zakończenia musi być późniejsza niż rozpoczęcia.'); + return; + } + setMeetingBusy(true); + setMeetingError(''); + apiFetch('/meetings', { + method: 'POST', + body: JSON.stringify({ + conversationId, + title: activeThreadListing?.title ? `Oglądanie: ${activeThreadListing.title}` : 'Oglądanie nieruchomości', + location: meetingForm.location.trim(), + date: meetingForm.date, + start: meetingForm.start, + end: meetingForm.end, + }), + }) + .then(() => apiFetch(`/conversations/${conversationId}`)) + .then((detail) => { + applyConversationDetail(detail); + setMeetingFormOpen(false); + setMeetingFeedback('Propozycja wysłana. Czekasz na potwierdzenie drugiej strony.'); + }) + .catch((error: Error) => setMeetingError(error.message || 'Nie udało się wysłać propozycji.')) + .finally(() => setMeetingBusy(false)); + }; + // Wizytowka to zwykla wiadomosc tekstowa ze znacznikiem - zostaje w historii rozmowy na serwerze. const deliverBusinessCard = (card: BusinessCard) => { void postToActiveThread(formatBusinessCard(card)); @@ -25864,6 +25872,28 @@ function MessagesPage({ }} disabled={!activeThreadListing} > Zobacz ogłoszenie + {/* Termin ogladania uzgadniamy w rozmowie - druga strona potwierdza go w swoim kalendarzu. */} + {!isAdminThread && activeThread.conversationId && ( + <> + + {meetingFormOpen && ( +
+ +
+ + +
+ + {meetingError &&

{meetingError}

} + + Druga strona potwierdzi termin albo zaproponuje inny. Wszystkie terminy zobaczysz w kalendarzu spotkań. +
+ )} + {meetingFeedback &&

{meetingFeedback}

} + + )} {/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */} {!isAdminThread && ( <> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0ea7cdc..2d933cd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -35233,3 +35233,80 @@ a.listing-detail-secondary { font-weight: 900; margin: 0 0 12px; } + +/* Propozycja ogladania w rozmowie i podpowiedz w kalendarzu spotkan. */ +.messages-meeting-form { + background: #f7fafc; + border: 1px solid #dde5ee; + border-radius: 10px; + display: grid; + gap: 10px; + margin-bottom: 10px; + padding: 12px; +} + +.messages-meeting-form label { + color: #4a5a6f; + display: grid; + font-size: 12px; + font-weight: 800; + gap: 4px; +} + +.messages-meeting-form input { + border: 1px solid #d7e0eb; + border-radius: 8px; + font-size: 13px; + padding: 8px 10px; +} + +.messages-meeting-times { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; +} + +.messages-meeting-send { + background: #10a15e; + border: 0; + border-radius: 8px; + color: #ffffff; + cursor: pointer; + font-size: 13px; + font-weight: 900; + min-height: 40px; +} + +.messages-meeting-form small { + color: #6b7f96; + font-size: 11px; + line-height: 1.4; +} + +.messages-meeting-error, +.messages-meeting-ok { + font-size: 12px; + font-weight: 800; + margin: 0 0 8px; +} + +.messages-meeting-error { + color: #c0392b; +} + +.messages-meeting-ok { + color: #0b9f5c; +} + +.meetings-hint { + color: #6b7f96; + font-size: 12px; + line-height: 1.5; + margin: 12px 0 0; +} + +.meeting-row-actions a { + align-items: center; + color: #526171; + display: inline-flex; +} diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts index e6fac2a..69acdb9 100644 --- a/frontend/tests/routing.spec.ts +++ b/frontend/tests/routing.spec.ts @@ -531,3 +531,38 @@ test.describe('Zapisane wyszukiwania', () => { await expect(page.locator('main')).not.toContainText('Gdynia'); }); }); + +// Spotkania sa na koncie, a ogladanie umawiaja obie strony rozmowy o ogloszeniu. +test.describe('Spotkania', () => { + test('wlasny wpis w kalendarzu przezywa odswiezenie', async ({ page }) => { + await login(page); + await goto(page, ROUTES.accountMeetings); + await page.getByRole('button', { name: /Dodaj własny wpis/ }).click(); + const title = `Notatka ${Date.now()}`; + await page.locator('.meetings-create-form input[type=text]').first().fill(title); + await page.getByRole('button', { name: 'Zapisz wpis' }).click(); + await expect(page.locator('.meetings-list')).toContainText(title); + + await page.reload(); + await expect(page.locator('.meetings-list')).toContainText(title); + + await page.locator('button[aria-label="Usuń wpis"]').first().click(); + await expect(page.locator('.meetings-list')).not.toContainText(title); + }); + + test('w rozmowie o ogloszeniu mozna zaproponowac ogladanie', 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(() => window.location.pathname.startsWith('/wiadomosci/')); + await page.locator('.messages-composer input[type=text]').fill('Dzien dobry, czy oferta jest aktualna?'); + await page.locator('.messages-composer .send').click(); + await page.waitForFunction(() => /wiadomosci/.test(window.location.pathname) && window.location.pathname.split('/').length === 3); + + await page.getByRole('button', { name: /Zaproponuj oglądanie/ }).click(); + await expect(page.locator('.messages-meeting-form')).toBeVisible(); + }); +}); diff --git a/scripts/cleanup-test-data.sql b/scripts/cleanup-test-data.sql index 79c850a..8ceef29 100644 --- a/scripts/cleanup-test-data.sql +++ b/scripts/cleanup-test-data.sql @@ -109,6 +109,10 @@ DELETE FROM auth_tokens WHERE lower(user_email) NOT IN (SELECT email FROM k DELETE FROM phone_otps WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails); DELETE FROM support_messages WHERE lower(account_email) NOT IN (SELECT email FROM keep_emails); DELETE FROM saved_searches WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails); +-- Spotkanie z usunietym kontem znika w calosci, takze po stronie konta, ktore zostaje. +DELETE FROM meetings + WHERE proposer_id NOT IN (SELECT id FROM keep_users) + OR (counterpart_id IS NOT NULL AND counterpart_id NOT IN (SELECT id FROM keep_users)); DELETE FROM user_settings WHERE user_id NOT IN (SELECT id FROM keep_users); DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);