Spotkania na serwerze: umawianie ogladania z druga strona
Deploy Polska Lokalnie / deploy (push) Successful in 12s

Nowa tabela meetings i /api/meetings. Termin proponuje jedna ze stron
rozmowy o ogloszeniu, druga potwierdza albo odrzuca; odwolac moze kazda
ze stron. Kalendarz konta pokazuje oba rodzaje wpisow - umowione
ogladania i wlasne notatki - i jest ten sam na kazdym urzadzeniu.
Powiadomienia: wpis w watku i dzwonek przy kazdej zmianie, e-mail
wylacznie przy potwierdzeniu terminu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 09:57:35 +02:00
parent e85cc70982
commit b9fecdea85
11 changed files with 1029 additions and 240 deletions
@@ -106,6 +106,28 @@ public class AccountEmailService {
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link); 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() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Miejsce:</strong> " + HtmlUtils.htmlEscape(location) + "</p>";
String about = listingTitle == null || listingTitle.isBlank() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Ogłoszenie:</strong> " + HtmlUtils.htmlEscape(listingTitle) + "</p>";
String body = "<p>Termin oglądania został potwierdzony przez: <strong>"
+ HtmlUtils.htmlEscape(otherName) + "</strong>.</p>"
+ "<div style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;border-radius:6px;\">"
+ "<p style=\"margin:0 0 4px\"><strong>Termin:</strong> " + HtmlUtils.htmlEscape(when) + "</p>"
+ where + about + "</div>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Jeśli termin przestanie pasować, odwołaj go w serwisie -"
+ " druga strona od razu to zobaczy.</p>";
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) { private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp(); SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled() boolean configured = smtp.isEnabled()
@@ -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.
*
* <p>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.</p>
*/
@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;
}
}
@@ -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<MeetingResponse> 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<Void> 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"));
}
}
@@ -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) {
}
}
@@ -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<Meeting, Long> {
@Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId"
+ " order by m.meetingDate asc, m.startTime asc")
List<Meeting> findForUser(@Param("userId") Long userId);
}
@@ -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.
*
* <p>Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail
* wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.</p>
*/
@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<MeetingResponse> list(AppUser me) {
List<Meeting> meetings = meetingRepository.findForUser(me.getId());
Map<Long, AppUser> 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();
}
}
@@ -167,6 +167,24 @@ public class ConversationService {
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId()); 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) { private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) {
textModerationService.validateOrThrow(content); textModerationService.validateOrThrow(content);
String trimmed = content.trim(); String trimmed = content.trim();
+270 -240
View File
@@ -271,14 +271,6 @@ const LIVE_LISTINGS_REFRESH_MS = 15000;
type NotificationCategory = 'listing' | 'messages' | 'system'; 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 FavoriteTab = 'buy' | 'rent';
type FavoriteListing = { type FavoriteListing = {
@@ -6472,24 +6464,26 @@ function AccountSidebar() {
const messagesUnreadCount = useMessagesUnreadCount(); const messagesUnreadCount = useMessagesUnreadCount();
// Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty. // Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty.
const [allListingsCount, setAllListingsCount] = useState(0); const [allListingsCount, setAllListingsCount] = useState(0);
// Spotkania zaplanowane na dzisiaj (odwolane sie nie licza). // Spotkania na dzisiaj (odwolane i odrzucone sie nie licza) - z konta, nie z przegladarki.
const todayMeetingsCount = (() => { const [todayMeetingsCount, setTodayMeetingsCount] = useState(0);
if (typeof window === 'undefined') { useEffect(() => {
return 0; if (!user) {
setTodayMeetingsCount(0);
return;
} }
try { let cancelled = false;
const raw = window.localStorage.getItem(meetingsStorageKey); const now = new Date();
const parsed = raw ? JSON.parse(raw) : []; const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
if (!Array.isArray(parsed)) { apiFetch<ApiMeeting[]>('/meetings')
return 0; .then((items) => {
} if (!cancelled) {
const now = new Date(); setTodayMeetingsCount(items.filter((item) => item.date === todayKey
const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; && item.status !== 'CANCELLED' && item.status !== 'DECLINED').length);
return parsed.filter((item) => item && !LEGACY_DEMO_MEETING_IDS.has(item.id) && item.dateKey === todayKey && item.status !== 'cancelled').length; }
} catch { })
return 0; .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. // Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
const [savedSearchCount, setSavedSearchCount] = useState(0); const [savedSearchCount, setSavedSearchCount] = useState(0);
useEffect(() => { useEffect(() => {
@@ -9095,22 +9089,54 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
); );
} }
function AccountMeetingsPage() { // Spotkanie z API: ogladanie umowione z druga strona rozmowy albo wlasny wpis w kalendarzu.
const { notify } = useNotifications(); type ApiMeeting = {
type MeetingType = 'buy' | 'rent' | 'own'; id: number;
type MeetingStatus = 'planned' | 'completed' | 'cancelled'; conversationId: number | null;
type MeetingItem = { listingId: number | null;
id: string; listingTitle: string | null;
title: string; title: string;
subtitle: string; note: string | null;
location: string; location: string | null;
dateKey: string; date: string;
start: string; start: string;
end: string; end: string;
type: MeetingType; status: 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED';
status: MeetingStatus; 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 formatDateKey = (value: Date) => {
const year = value.getFullYear(); const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0'); const month = String(value.getMonth() + 1).padStart(2, '0');
@@ -9118,15 +9144,16 @@ function AccountMeetingsPage() {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}; };
const storageKey = 'polskalokalnie-account-meetings';
const [calendarMonth, setCalendarMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth(), 1)); const [calendarMonth, setCalendarMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const [selectedDateKey, setSelectedDateKey] = useState(() => formatDateKey(new Date())); const [selectedDateKey, setSelectedDateKey] = useState(() => formatDateKey(new Date()));
const [showBuyMeetings, setShowBuyMeetings] = useState(true); const [showSharedMeetings, setShowSharedMeetings] = useState(true);
const [showRentMeetings, setShowRentMeetings] = useState(true);
const [showOwnMeetings, setShowOwnMeetings] = useState(true); const [showOwnMeetings, setShowOwnMeetings] = useState(true);
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false); const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
const [editingMeetingId, setEditingMeetingId] = useState<string | null>(null); const [editingMeetingId, setEditingMeetingId] = useState<number | null>(null);
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState('');
const [busy, setBusy] = useState(false);
const [meetings, setMeetings] = useState<ApiMeeting[]>([]);
const [loading, setLoading] = useState(true);
const [newMeeting, setNewMeeting] = useState({ const [newMeeting, setNewMeeting] = useState({
title: '', title: '',
subtitle: '', subtitle: '',
@@ -9134,41 +9161,23 @@ function AccountMeetingsPage() {
dateKey: formatDateKey(new Date()), dateKey: formatDateKey(new Date()),
start: '10:00', start: '10:00',
end: '10:30', end: '10:30',
type: 'buy' as MeetingType,
});
const [meetings, setMeetings] = useState<MeetingItem[]>(() => {
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<ApiMeeting[]>('/meetings')
.then((items) => { setMeetings(items); return items; })
.catch(() => { setMeetings([]); return [] as ApiMeeting[]; })
.finally(() => setLoading(false))
), []);
useEffect(() => { useEffect(() => {
window.localStorage.setItem(storageKey, JSON.stringify(meetings)); let cancelled = false;
}, [meetings]); apiFetch<ApiMeeting[]>('/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 monthLabel = calendarMonth.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' });
const todayKey = formatDateKey(new Date()); const todayKey = formatDateKey(new Date());
@@ -9182,18 +9191,15 @@ function AccountMeetingsPage() {
}); });
const visibleSelectedMeetings = meetings const visibleSelectedMeetings = meetings
.filter((meeting) => meeting.dateKey === selectedDateKey) .filter((meeting) => meeting.date === selectedDateKey)
.filter((meeting) => ( .filter((meeting) => (meeting.privateNote ? showOwnMeetings : showSharedMeetings))
(meeting.type === 'buy' && showBuyMeetings)
|| (meeting.type === 'rent' && showRentMeetings)
|| (meeting.type === 'own' && showOwnMeetings)
))
.sort((left, right) => left.start.localeCompare(right.start)); .sort((left, right) => left.start.localeCompare(right.start));
const selectedDayMeetings = meetings.filter((meeting) => meeting.date === selectedDateKey);
const selectedDayTotals = { const selectedDayTotals = {
all: meetings.filter((meeting) => meeting.dateKey === selectedDateKey).length, all: selectedDayMeetings.length,
planned: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'planned').length, waiting: selectedDayMeetings.filter((meeting) => meeting.status === 'PROPOSED').length,
completed: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'completed').length, confirmed: selectedDayMeetings.filter((meeting) => meeting.status === 'CONFIRMED').length,
}; };
const selectedDateLabel = (() => { const selectedDateLabel = (() => {
@@ -9206,15 +9212,7 @@ function AccountMeetingsPage() {
}; };
const resetFormForDate = (dateKey: string) => { const resetFormForDate = (dateKey: string) => {
setNewMeeting({ setNewMeeting({ title: '', subtitle: '', location: '', dateKey, start: '10:00', end: '10:30' });
title: '',
subtitle: '',
location: '',
dateKey,
start: '10:00',
end: '10:30',
type: 'buy',
});
setEditingMeetingId(null); setEditingMeetingId(null);
setFormError(''); setFormError('');
}; };
@@ -9236,65 +9234,64 @@ function AccountMeetingsPage() {
return (hours * 60) + minutes; 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 candidateStartMinutes = toMinutes(candidateStart);
const candidateEndMinutes = toMinutes(candidateEnd); const candidateEndMinutes = toMinutes(candidateEnd);
return meetings.some((meeting) => { return meetings.some((meeting) => {
if (meeting.dateKey !== candidateDateKey || meeting.id === ignoreId) { if (meeting.date !== candidateDateKey || meeting.id === ignoreId) {
return false; return false;
} }
const startMinutes = toMinutes(meeting.start); if (meeting.status === 'CANCELLED' || meeting.status === 'DECLINED') {
const endMinutes = toMinutes(meeting.end); return false;
return candidateStartMinutes < endMinutes && candidateEndMinutes > startMinutes; }
return candidateStartMinutes < toMinutes(meetingTime(meeting.end))
&& candidateEndMinutes > toMinutes(meetingTime(meeting.start));
}); });
}; };
const setStatus = (id: string, status: MeetingStatus) => { const run = (action: Promise<unknown>, onError: string) => {
if (selectedDateIsPast) { setBusy(true);
return; action
} .then(() => reload())
const target = meetings.find((meeting) => meeting.id === id); .catch((error: Error) => setFormError(error.message || onError))
setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting))); .finally(() => setBusy(false));
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 removeMeeting = (id: string) => { const confirmMeeting = (meeting: ApiMeeting) => {
if (selectedDateIsPast) { run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/confirm`, { method: 'POST' }), 'Nie udało się potwierdzić terminu.');
return; };
}
setMeetings((current) => current.filter((meeting) => meeting.id !== id)); const declineMeeting = (meeting: ApiMeeting) => {
if (editingMeetingId === id) { run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/decline`, { method: 'POST' }), 'Nie udało się odrzucić terminu.');
};
const cancelMeeting = (meeting: ApiMeeting) => {
run(apiFetch<void>(`/meetings/${meeting.id}`, { method: 'DELETE' }), 'Nie udało się odwołać spotkania.');
if (editingMeetingId === meeting.id) {
setEditingMeetingId(null); setEditingMeetingId(null);
setIsCreateFormOpen(false); setIsCreateFormOpen(false);
} }
}; };
const openEditMeeting = (meeting: MeetingItem) => { const openEditMeeting = (meeting: ApiMeeting) => {
if (isPastDateKey(meeting.dateKey)) {
return;
}
setEditingMeetingId(meeting.id); setEditingMeetingId(meeting.id);
setNewMeeting({ setNewMeeting({
title: meeting.title, title: meeting.title,
subtitle: meeting.subtitle, subtitle: meeting.note ?? '',
location: meeting.location, location: meeting.location ?? '',
dateKey: meeting.dateKey, dateKey: meeting.date,
start: meeting.start, start: meetingTime(meeting.start),
end: meeting.end, end: meetingTime(meeting.end),
type: meeting.type,
}); });
setFormError(''); setFormError('');
setSelectedDateKey(meeting.dateKey); setSelectedDateKey(meeting.date);
const selectedDate = new Date(meeting.dateKey); const selectedDate = new Date(meeting.date);
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setIsCreateFormOpen(true); setIsCreateFormOpen(true);
}; };
const addMeeting = () => { const saveMeeting = () => {
if (isPastDateKey(newMeeting.dateKey)) { if (isPastDateKey(newMeeting.dateKey)) {
setFormError('Nie można dodawać ani edytować spotkań wstecz.'); setFormError('Nie można dodawać ani edytować spotkań wstecz.');
return; return;
@@ -9312,66 +9309,31 @@ function AccountMeetingsPage() {
return; return;
} }
if (editingMeetingId) { const payload = {
setMeetings((current) => current.map((meeting) => (meeting.id === editingMeetingId title: newMeeting.title.trim(),
? { note: newMeeting.subtitle.trim(),
...meeting, location: newMeeting.location.trim(),
title: newMeeting.title.trim(), date: newMeeting.dateKey,
subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane', start: newMeeting.start,
location: newMeeting.location.trim() || 'Do uzupełnienia', end: newMeeting.end,
dateKey: newMeeting.dateKey, };
start: newMeeting.start, const request = editingMeetingId
end: newMeeting.end, ? apiFetch<ApiMeeting>(`/meetings/${editingMeetingId}`, { method: 'PUT', body: JSON.stringify(payload) })
type: newMeeting.type, : apiFetch<ApiMeeting>('/meetings', { method: 'POST', body: JSON.stringify(payload) });
}
: 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',
},
]);
}
if (!editingMeetingId) { setBusy(true);
notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” - ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' }); request
} .then(() => reload())
.then(() => {
setFormError(''); setFormError('');
setSelectedDateKey(newMeeting.dateKey); setSelectedDateKey(newMeeting.dateKey);
const selectedDate = new Date(newMeeting.dateKey); const selectedDate = new Date(newMeeting.dateKey);
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
resetFormForDate(newMeeting.dateKey); resetFormForDate(newMeeting.dateKey);
setIsCreateFormOpen(false); setIsCreateFormOpen(false);
}; })
.catch((error: Error) => setFormError(error.message || 'Nie udało się zapisać spotkania.'))
const markerClass = (type: MeetingType) => { .finally(() => setBusy(false));
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';
}; };
useEffect(() => { useEffect(() => {
@@ -9392,7 +9354,7 @@ function AccountMeetingsPage() {
<div className="account-main account-settings-main meetings-main"> <div className="account-main account-settings-main meetings-main">
<header className="settings-head meetings-head"> <header className="settings-head meetings-head">
<h1>Umówione spotkania</h1> <h1>Umówione spotkania</h1>
<p>Zarządzaj swoimi spotkaniami i przeglądaj nadchodzące terminy.</p> <p>Oglądania umówione w rozmowach o ogłoszeniach oraz Twoje własne wpisy w kalendarzu.</p>
</header> </header>
<div className="meetings-grid"> <div className="meetings-grid">
@@ -9408,8 +9370,7 @@ function AccountMeetingsPage() {
className="meetings-today-button" className="meetings-today-button"
onClick={() => { onClick={() => {
const today = new Date(); const today = new Date();
const todayKey = formatDateKey(today); setSelectedDateKey(formatDateKey(today));
setSelectedDateKey(todayKey);
setCalendarMonth(new Date(today.getFullYear(), today.getMonth(), 1)); setCalendarMonth(new Date(today.getFullYear(), today.getMonth(), 1));
}} }}
> >
@@ -9426,7 +9387,7 @@ function AccountMeetingsPage() {
const isOutside = date.getMonth() !== calendarMonth.getMonth(); const isOutside = date.getMonth() !== calendarMonth.getMonth();
const isActive = key === selectedDateKey; const isActive = key === selectedDateKey;
const isPast = isPastDateKey(key); const isPast = isPastDateKey(key);
const hasMeeting = meetings.some((meeting) => meeting.dateKey === key); const hasMeeting = meetings.some((meeting) => meeting.date === key && meeting.status !== 'CANCELLED');
return ( return (
<button <button
@@ -9447,20 +9408,22 @@ function AccountMeetingsPage() {
</div> </div>
<button type="button" className="meetings-add-button" onClick={openCreateForm} disabled={selectedDateIsPast}> <button type="button" className="meetings-add-button" onClick={openCreateForm} disabled={selectedDateIsPast}>
<Icon name="plus" /> Dodaj spotkanie <Icon name="plus" /> Dodaj własny wpis
</button> </button>
<section className="meetings-filters-card"> <section className="meetings-filters-card">
<h3>Filtry</h3> <h3>Filtry</h3>
<label><input type="checkbox" checked={showBuyMeetings && showRentMeetings && showOwnMeetings} onChange={(event) => { <label><input type="checkbox" checked={showSharedMeetings && showOwnMeetings} onChange={(event) => {
setShowBuyMeetings(event.target.checked); setShowSharedMeetings(event.target.checked);
setShowRentMeetings(event.target.checked);
setShowOwnMeetings(event.target.checked); setShowOwnMeetings(event.target.checked);
}} /> Pokaż wszystkie</label> }} /> Pokaż wszystkie</label>
<label><input type="checkbox" checked={showBuyMeetings} onChange={(event) => setShowBuyMeetings(event.target.checked)} /> Spotkania z kupującymi</label> <label><input type="checkbox" checked={showSharedMeetings} onChange={(event) => setShowSharedMeetings(event.target.checked)} /> Oglądania z drugą stroną</label>
<label><input type="checkbox" checked={showRentMeetings} onChange={(event) => setShowRentMeetings(event.target.checked)} /> Spotkania z wynajmującymi</label> <label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Wpisy własne</label>
<label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Spotkania własne</label>
</section> </section>
<p className="meetings-hint">
Termin oglądania proponujesz w rozmowie o ogłoszeniu - <Link to={ROUTES.messages}>przejdź do wiadomości</Link>.
</p>
</aside> </aside>
<section className="meetings-list-card"> <section className="meetings-list-card">
@@ -9468,8 +9431,8 @@ function AccountMeetingsPage() {
<h2>{selectedDateLabel}</h2> <h2>{selectedDateLabel}</h2>
<div className="meetings-day-summary"> <div className="meetings-day-summary">
<span>Wszystkie: {selectedDayTotals.all}</span> <span>Wszystkie: {selectedDayTotals.all}</span>
<span>Zaplanowane: {selectedDayTotals.planned}</span> <span>Do potwierdzenia: {selectedDayTotals.waiting}</span>
<span>Zrealizowane: {selectedDayTotals.completed}</span> <span>Potwierdzone: {selectedDayTotals.confirmed}</span>
</div> </div>
{selectedDateIsPast && ( {selectedDateIsPast && (
<p className="meetings-history-note">Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.</p> <p className="meetings-history-note">Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.</p>
@@ -9477,50 +9440,54 @@ function AccountMeetingsPage() {
</header> </header>
<div className="meetings-list"> <div className="meetings-list">
{visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>} {loading && <p className="meetings-empty">Wczytujemy kalendarz…</p>}
{visibleSelectedMeetings.map((meeting) => ( {!loading && visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>}
<article key={meeting.id}> {visibleSelectedMeetings.map((meeting) => {
<div className="time">{meeting.start}<small>- {meeting.end}</small></div> const canDecide = !meeting.privateNote && meeting.status === 'PROPOSED' && !meeting.proposedByMe;
<div className="info"> const canCancel = meeting.status === 'PROPOSED' || meeting.status === 'CONFIRMED';
<strong><i className={markerClass(meeting.type)} />{meeting.title}</strong> return (
<p>{meeting.subtitle}</p> <article key={meeting.id}>
<small>{meeting.location}</small> <div className="time">{meetingTime(meeting.start)}<small>- {meetingTime(meeting.end)}</small></div>
</div> <div className="info">
<div className="meeting-side-actions"> <strong><i className={meeting.privateNote ? 'own' : 'buy'} />{meeting.title}</strong>
<em className={`status-${meeting.status}`}>{statusLabel(meeting.status)}</em> <p>{meeting.privateNote ? (meeting.note || 'Wpis własny') : `Z: ${meeting.otherName ?? 'druga strona'}${meeting.listingTitle ? ` · ${meeting.listingTitle}` : ''}`}</p>
{!selectedDateIsPast && ( <small>{meeting.location || 'Miejsce do uzgodnienia'}</small>
<div className="meeting-row-actions"> </div>
{meeting.status !== 'completed' && ( <div className="meeting-side-actions">
<button type="button" onClick={() => setStatus(meeting.id, 'completed')} aria-label="Oznacz jako zrealizowane"><Icon name="check" /></button> <em className={meetingStatusClass(meeting)}>{meetingStatusLabel(meeting)}</em>
)} {!selectedDateIsPast && (
{meeting.status !== 'planned' && ( <div className="meeting-row-actions">
<button type="button" onClick={() => setStatus(meeting.id, 'planned')} aria-label="Oznacz jako zaplanowane"><Icon name="clock" /></button> {canDecide && (
)} <button type="button" disabled={busy} onClick={() => confirmMeeting(meeting)} aria-label="Potwierdź termin"><Icon name="check" /></button>
<button type="button" onClick={() => setStatus(meeting.id, 'cancelled')} aria-label="Oznacz jako odwołane"><Icon name="warning" /></button> )}
<button type="button" onClick={() => openEditMeeting(meeting)} aria-label="Edytuj spotkanie"><Icon name="gear" /></button> {canDecide && (
<button type="button" onClick={() => removeMeeting(meeting.id)} aria-label="Usuń spotkanie"><Icon name="trash" /></button> <button type="button" disabled={busy} onClick={() => declineMeeting(meeting)} aria-label="Odrzuć termin"><Icon name="warning" /></button>
</div> )}
)} {meeting.privateNote && (
</div> <button type="button" disabled={busy} onClick={() => openEditMeeting(meeting)} aria-label="Edytuj wpis"><Icon name="gear" /></button>
</article> )}
))} {meeting.conversationId != null && (
<Link to={conversationPath(meeting.conversationId)} aria-label="Otwórz rozmowę"><Icon name="message" /></Link>
)}
{canCancel && (
<button type="button" disabled={busy} onClick={() => cancelMeeting(meeting)} aria-label={meeting.privateNote ? 'Usuń wpis' : 'Odwołaj spotkanie'}><Icon name="trash" /></button>
)}
</div>
)}
</div>
</article>
);
})}
</div> </div>
{isCreateFormOpen && ( {isCreateFormOpen && (
<div className="meetings-create-form"> <div className="meetings-create-form">
<h3>{editingMeetingId ? 'Edytuj spotkanie' : 'Dodaj spotkanie ręcznie'}</h3> <h3>{editingMeetingId ? 'Edytuj wpis' : 'Dodaj własny wpis'}</h3>
<div className="fields"> <div className="fields">
<label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="np. Spotkanie z kupującym" /></label> <label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="Np. oglądanie mieszkania" /></label>
<label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="np. Mieszkanie 3-pokojowe, 62 m²" /></label> <label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="Np. notatka dla siebie" /></label>
<label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="np. ul. Mokotowska 45, Warszawa" /></label> <label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="Np. ul. Długa 5" /></label>
<label>Data<input type="date" min={todayKey} value={newMeeting.dateKey} onChange={(event) => setNewMeeting((current) => ({ ...current, dateKey: event.target.value }))} /></label> <label>Data<input type="date" min={todayKey} value={newMeeting.dateKey} onChange={(event) => setNewMeeting((current) => ({ ...current, dateKey: event.target.value }))} /></label>
<label>Typ
<select value={newMeeting.type} onChange={(event) => setNewMeeting((current) => ({ ...current, type: event.target.value as MeetingType }))}>
<option value="buy">Spotkanie z kupującym</option>
<option value="rent">Spotkanie z wynajmującym</option>
<option value="own">Spotkanie własne</option>
</select>
</label>
<label>Godzina od<input type="time" value={newMeeting.start} onChange={(event) => setNewMeeting((current) => ({ ...current, start: event.target.value }))} /></label> <label>Godzina od<input type="time" value={newMeeting.start} onChange={(event) => setNewMeeting((current) => ({ ...current, start: event.target.value }))} /></label>
<label>Godzina do<input type="time" value={newMeeting.end} onChange={(event) => setNewMeeting((current) => ({ ...current, end: event.target.value }))} /></label> <label>Godzina do<input type="time" value={newMeeting.end} onChange={(event) => setNewMeeting((current) => ({ ...current, end: event.target.value }))} /></label>
</div> </div>
@@ -9536,10 +9503,11 @@ function AccountMeetingsPage() {
> >
Anuluj Anuluj
</button> </button>
<button type="button" className="primary" onClick={addMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz spotkanie'}</button> <button type="button" className="primary" disabled={busy} onClick={saveMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz wpis'}</button>
</div> </div>
</div> </div>
)} )}
{!isCreateFormOpen && formError && <p className="meetings-error">{formError}</p>}
</section> </section>
</div> </div>
</div> </div>
@@ -24927,6 +24895,13 @@ function MessagesPage({
const [activeFilter, setActiveFilter] = useState<ThreadFilter>('all'); const [activeFilter, setActiveFilter] = useState<ThreadFilter>('all');
const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const [composerValue, setComposerValue] = useState(''); 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 [isSending, setIsSending] = useState(false);
const [moderationFeedback, setModerationFeedback] = useState(''); const [moderationFeedback, setModerationFeedback] = useState('');
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
@@ -25364,6 +25339,39 @@ function MessagesPage({
void postToActiveThread(value); 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<ApiMeeting>('/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<ApiConversationDetail>(`/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. // Wizytowka to zwykla wiadomosc tekstowa ze znacznikiem - zostaje w historii rozmowy na serwerze.
const deliverBusinessCard = (card: BusinessCard) => { const deliverBusinessCard = (card: BusinessCard) => {
void postToActiveThread(formatBusinessCard(card)); void postToActiveThread(formatBusinessCard(card));
@@ -25864,6 +25872,28 @@ function MessagesPage({
}} }}
disabled={!activeThreadListing} disabled={!activeThreadListing}
><span><Icon name="house" /> Zobacz ogłoszenie</span><Icon name="link" /></button> ><span><Icon name="house" /> Zobacz ogłoszenie</span><Icon name="link" /></button>
{/* Termin ogladania uzgadniamy w rozmowie - druga strona potwierdza go w swoim kalendarzu. */}
{!isAdminThread && activeThread.conversationId && (
<>
<button type="button" onClick={() => { setMeetingFormOpen((open) => !open); setMeetingError(''); setMeetingFeedback(''); }}>
<span><Icon name="calendar" /> Zaproponuj oglądanie</span><Icon name="chevron" />
</button>
{meetingFormOpen && (
<div className="messages-meeting-form">
<label>Data<input type="date" min={meetingToday} value={meetingForm.date} onChange={(event) => setMeetingForm((current) => ({ ...current, date: event.target.value }))} /></label>
<div className="messages-meeting-times">
<label>Od<input type="time" value={meetingForm.start} onChange={(event) => setMeetingForm((current) => ({ ...current, start: event.target.value }))} /></label>
<label>Do<input type="time" value={meetingForm.end} onChange={(event) => setMeetingForm((current) => ({ ...current, end: event.target.value }))} /></label>
</div>
<label>Miejsce<input type="text" value={meetingForm.location} placeholder="Np. adres z ogłoszenia" onChange={(event) => setMeetingForm((current) => ({ ...current, location: event.target.value }))} /></label>
{meetingError && <p className="messages-meeting-error">{meetingError}</p>}
<button type="button" className="messages-meeting-send" disabled={meetingBusy} onClick={proposeMeeting}>Wyślij propozycję</button>
<small>Druga strona potwierdzi termin albo zaproponuje inny. Wszystkie terminy zobaczysz w <Link to={ROUTES.accountMeetings}>kalendarzu spotkań</Link>.</small>
</div>
)}
{meetingFeedback && <p className="messages-meeting-ok" role="status">{meetingFeedback}</p>}
</>
)}
{/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */} {/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */}
{!isAdminThread && ( {!isAdminThread && (
<> <>
+77
View File
@@ -35233,3 +35233,80 @@ a.listing-detail-secondary {
font-weight: 900; font-weight: 900;
margin: 0 0 12px; 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;
}
+35
View File
@@ -531,3 +531,38 @@ test.describe('Zapisane wyszukiwania', () => {
await expect(page.locator('main')).not.toContainText('Gdynia'); 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();
});
});
+4
View File
@@ -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 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 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); 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 user_settings WHERE user_id NOT IN (SELECT id FROM keep_users);
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users); DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);