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);
}
/**
* 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) {
SmtpConfig smtp = mailConfigService.getSmtp();
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());
}
/**
* 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();