Compare commits
54 Commits
bd07f90e3b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 48dba240ee | |||
| d9819d0488 | |||
| cf02f480ed | |||
| 517e80d6bd | |||
| 09d4b2f32b | |||
| c9c78b6db5 | |||
| ca5f3ebfc1 | |||
| a4457df5f8 | |||
| 008223f311 | |||
| ad1d573e89 | |||
| a53cae7c37 | |||
| b850e4c092 | |||
| 0d149467e9 | |||
| a6c2378cbc | |||
| ac7a3a4efb | |||
| 3a555e3b24 | |||
| 1c1162c93e | |||
| c740612b06 | |||
| d4dd898387 | |||
| b5d121e24e | |||
| 036cb59331 | |||
| dbb6cf58d0 | |||
| 45a32d7ef0 | |||
| d6b6e3edd9 | |||
| 557c76257b | |||
| b9fecdea85 | |||
| e85cc70982 | |||
| 8cd2db38cc | |||
| e3cc999343 | |||
| 017ff4b2c6 | |||
| 556dd6cf6a | |||
| bad91dc205 | |||
| 94dd300c53 | |||
| e92c68ec93 | |||
| e7402dbfed | |||
| 72af24469a | |||
| 9615f131f4 | |||
| 5eee33b986 | |||
| d655bcc9b1 | |||
| bc2f5ce634 | |||
| 4453ef2305 | |||
| a3e4505194 | |||
| 6b334cc1b5 | |||
| fbfb0db38e | |||
| c981b3cb93 | |||
| e7db409828 | |||
| 762b76f141 | |||
| cc96c06c84 | |||
| ee83ac43c4 | |||
| 282b5e424f | |||
| d973b44e47 | |||
| 52e1f2cb96 | |||
| 642030ea41 | |||
| 7aad27a514 |
@@ -9,3 +9,26 @@ SPRING_JPA_HIBERNATE_DDL_AUTO=update
|
|||||||
WEB_BIND_ADDRESS=0.0.0.0
|
WEB_BIND_ADDRESS=0.0.0.0
|
||||||
BACKEND_BIND_ADDRESS=127.0.0.1
|
BACKEND_BIND_ADDRESS=127.0.0.1
|
||||||
POSTGRES_BIND_ADDRESS=127.0.0.1
|
POSTGRES_BIND_ADDRESS=127.0.0.1
|
||||||
|
|
||||||
|
# --- Konto administratora ---
|
||||||
|
# Uzywane przy pierwszym starcie do zalozenia konta. Logowanie odbywa sie ADRESEM E-MAIL.
|
||||||
|
APP_ADMIN_EMAIL=admin@softspm.com
|
||||||
|
APP_ADMIN_PASSWORD=Admin123!
|
||||||
|
APP_ADMIN_NAME=Administrator Polska Lokalnie
|
||||||
|
|
||||||
|
# Gdy konto administratora juz istnieje, APP_ADMIN_PASSWORD jest ignorowane - inaczej restart
|
||||||
|
# cofalby haslo zmienione w ustawieniach konta. Ustaw ponizsze na true, zrestartuj backend i
|
||||||
|
# wroc do false, zeby awaryjnie przywrocic haslo z APP_ADMIN_PASSWORD.
|
||||||
|
APP_ADMIN_RESET_PASSWORD=false
|
||||||
|
|
||||||
|
# --- Sesje ---
|
||||||
|
# Min. 32 znaki. W produkcji ustaw wlasna wartosc - domyslna zna kazdy, kto widzial repozytorium.
|
||||||
|
APP_JWT_SECRET=local-dev-secret-change-me-please-32chars-min
|
||||||
|
|
||||||
|
# Adres bazowy w linkach z e-maili (aktywacja konta, reset hasla).
|
||||||
|
APP_PUBLIC_BASE_URL=http://localhost
|
||||||
|
|
||||||
|
# --- Platnosci ---
|
||||||
|
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest potwierdzany automatycznie.
|
||||||
|
# Wylacznie lokalnie (docker-compose.yml). Produkcja tej zmiennej nie czyta.
|
||||||
|
APP_PAYMENTS_AUTO_CONFIRM=true
|
||||||
|
|||||||
@@ -111,3 +111,42 @@ frontend/ React + Vite
|
|||||||
nginx/ konfiguracja Nginx i obraz web
|
nginx/ konfiguracja Nginx i obraz web
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Jakiś testowy wpis
|
||||||
|
|
||||||
|
## Wdrozenie na produkcje
|
||||||
|
|
||||||
|
Deploy jest automatyczny: push na `main` uruchamia workflow Gitea, ktory wola
|
||||||
|
`/opt/polskalokalnie/deploy-prod.sh` na serwerze. Skrypt pobiera `main`, ustawia katalog
|
||||||
|
aplikacji dokladnie na stan repozytorium (`git reset --hard`) i przebudowuje stack.
|
||||||
|
|
||||||
|
Konfiguracja produkcyjna siedzi w `/opt/polskalokalnie/app/.env.production`, ktory jest
|
||||||
|
w `.gitignore`. Zmienne trafiaja do kontenera tylko wtedy, gdy sa wymienione w sekcji
|
||||||
|
`environment` w `docker-compose.prod.yml` - samo dopisanie ich do pliku `.env` nie wystarczy.
|
||||||
|
|
||||||
|
Reczne zmiany plikow sledzonych na serwerze sa przy kolejnym deployu nadpisywane, a ich lista
|
||||||
|
trafia do `/opt/polskalokalnie/deploy.log` jako `OSTRZEZENIE`. Poprawki nalezy wprowadzac
|
||||||
|
w repozytorium, nie na serwerze.
|
||||||
|
|
||||||
|
### Nazwa projektu Compose
|
||||||
|
|
||||||
|
Oba pliki compose maja `name: polskalokalnie`. To nie jest ozdobnik: na serwerze produkcyjnym
|
||||||
|
kazda z hostowanych aplikacji lezy w katalogu o nazwie `app`, a Compose bez tego wpisu
|
||||||
|
wyprowadza nazwe projektu z nazwy katalogu. Komenda `docker compose ...` bez `-p` wchodzila
|
||||||
|
wtedy w stack innej aplikacji i podmieniala w nim serwis `backend`.
|
||||||
|
|
||||||
|
Przy recznych operacjach na serwerze zawsze podawaj pelny zestaw parametrow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -p polskalokalnie -f docker-compose.prod.yml --env-file .env.production ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logowanie
|
||||||
|
|
||||||
|
Loginem jest **adres e-mail**. Nick jest nadawany automatycznie i sluzy wylacznie jako adres
|
||||||
|
profilu publicznego - nie mozna sie nim zalogowac.
|
||||||
|
|
||||||
|
Konto administratora powstaje przy pierwszym starcie z `APP_ADMIN_EMAIL` i `APP_ADMIN_PASSWORD`.
|
||||||
|
Gdy konto o tym adresie juz istnieje, haslo z konfiguracji jest ignorowane, zeby restart nie
|
||||||
|
cofal hasla zmienionego w ustawieniach konta. Awaryjne przywrocenie dostepu:
|
||||||
|
`APP_ADMIN_RESET_PASSWORD=true`, restart backendu, zalogowanie, powrot do `false`.
|
||||||
|
|||||||
+4
-3
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.3.5</version>
|
<version>3.5.16</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
@@ -64,9 +64,10 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Testy na prawdziwym PostgreSQL (tymczasowy kontener), tak jak produkcja - bez emulacji H2. -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package pl.polskalokalnie;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
|
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
|
||||||
// uzytkownika Spring Security (i mylacy log "Using generated security password").
|
// uzytkownika Spring Security (i mylacy log "Using generated security password").
|
||||||
// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu.
|
// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu.
|
||||||
|
// @EnableAsync - e-mail o nowej wiadomosci wychodzi w tle, zeby SMTP nie opoznial wysylki.
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||||
public class PolskaLokalnieApplication {
|
public class PolskaLokalnieApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
|
|||||||
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
|
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
|
||||||
import pl.polskalokalnie.support.SupportMessageResponse;
|
import pl.polskalokalnie.support.SupportMessageResponse;
|
||||||
import pl.polskalokalnie.support.SupportMessageService;
|
import pl.polskalokalnie.support.SupportMessageService;
|
||||||
|
import pl.polskalokalnie.user.AccountErasureService;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.BlockedEmail;
|
import pl.polskalokalnie.user.BlockedEmail;
|
||||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
import pl.polskalokalnie.user.UserSettingsService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/admin")
|
@RequestMapping("/api/admin")
|
||||||
@@ -64,6 +66,8 @@ public class AdminController {
|
|||||||
private final AdminStatsService adminStatsService;
|
private final AdminStatsService adminStatsService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final AccountErasureService accountErasureService;
|
||||||
|
private final UserSettingsService userSettingsService;
|
||||||
|
|
||||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||||
ListingService listingService, ListingReportService listingReportService,
|
ListingService listingService, ListingReportService listingReportService,
|
||||||
@@ -74,7 +78,9 @@ public class AdminController {
|
|||||||
TextModerationService textModerationService,
|
TextModerationService textModerationService,
|
||||||
AdminStatsService adminStatsService,
|
AdminStatsService adminStatsService,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
NotificationService notificationService) {
|
NotificationService notificationService,
|
||||||
|
AccountErasureService accountErasureService,
|
||||||
|
UserSettingsService userSettingsService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
@@ -87,6 +93,8 @@ public class AdminController {
|
|||||||
this.adminStatsService = adminStatsService;
|
this.adminStatsService = adminStatsService;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
|
this.accountErasureService = accountErasureService;
|
||||||
|
this.userSettingsService = userSettingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Uzytkownicy ---
|
// --- Uzytkownicy ---
|
||||||
@@ -166,6 +174,8 @@ public class AdminController {
|
|||||||
|
|
||||||
String temporaryPassword = generateTemporaryPassword();
|
String temporaryPassword = generateTemporaryPassword();
|
||||||
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
|
||||||
|
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
|
||||||
|
user.revokeSessions();
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
notificationService.create(user.getEmail(), "system", "lock",
|
notificationService.create(user.getEmail(), "system", "lock",
|
||||||
"Hasło zostało zresetowane",
|
"Hasło zostało zresetowane",
|
||||||
@@ -181,6 +191,7 @@ public class AdminController {
|
|||||||
if (user.getRole() == Role.ADMIN) {
|
if (user.getRole() == Role.ADMIN) {
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||||
}
|
}
|
||||||
|
listingService.deleteAllOfOwner(user.getEmail());
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ public class AdminStatsService {
|
|||||||
List<Message> messagesToAdmin = adminId == null
|
List<Message> messagesToAdmin = adminId == null
|
||||||
? List.of()
|
? List.of()
|
||||||
: messages.stream()
|
: messages.stream()
|
||||||
.filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
|
.filter(message -> message.getConversationId() == null
|
||||||
|
&& message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
|
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||||
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||||
import pl.polskalokalnie.send.EmailSender;
|
import pl.polskalokalnie.send.EmailSender;
|
||||||
@@ -70,7 +71,7 @@ public class AccountEmailService {
|
|||||||
boolean approved, String reason, String path) {
|
boolean approved, String reason, String path) {
|
||||||
String link = baseUrl + (path == null ? "" : path);
|
String link = baseUrl + (path == null ? "" : path);
|
||||||
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
|
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
|
||||||
String safeTitle = articleTitle == null ? "" : articleTitle;
|
String safeTitle = articleTitle == null ? "" : HtmlUtils.htmlEscape(articleTitle);
|
||||||
String body;
|
String body;
|
||||||
if (approved) {
|
if (approved) {
|
||||||
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
|
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
|
||||||
@@ -79,7 +80,7 @@ public class AccountEmailService {
|
|||||||
} else {
|
} else {
|
||||||
String reasonHtml = reason == null || reason.isBlank() ? ""
|
String reasonHtml = reason == null || reason.isBlank() ? ""
|
||||||
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
|
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
|
||||||
+ reason.trim() + "</p>";
|
+ HtmlUtils.htmlEscape(reason.trim()) + "</p>";
|
||||||
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
|
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
|
||||||
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
|
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
|
||||||
+ reasonHtml
|
+ reasonHtml
|
||||||
@@ -90,6 +91,43 @@ public class AccountEmailService {
|
|||||||
approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link);
|
approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nowa wiadomosc w rozmowie o ogloszeniu. Tresc pochodzi od uzytkownika, wiec jest escapowana. */
|
||||||
|
public void sendNewMessage(String email, String name, String senderName, String listingTitle,
|
||||||
|
String content, Long conversationId) {
|
||||||
|
String link = baseUrl + "/wiadomosci/" + conversationId;
|
||||||
|
String safeContent = HtmlUtils.htmlEscape(content == null ? "" : content).replace("\n", "<br>");
|
||||||
|
String body = "<p><strong>" + HtmlUtils.htmlEscape(senderName) + "</strong> napisał(a) w sprawie ogłoszenia"
|
||||||
|
+ " <strong>„" + HtmlUtils.htmlEscape(listingTitle) + "”</strong>:</p>"
|
||||||
|
+ "<blockquote style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;"
|
||||||
|
+ "border-radius:6px;\">" + safeContent + "</blockquote>"
|
||||||
|
+ "<p style=\"color:#6b7f96;font-size:13px\">Odpowiedz w serwisie - nie podawaj w wiadomościach danych"
|
||||||
|
+ " logowania, numeru konta ani kodów BLIK.</p>";
|
||||||
|
send(email, "Nowa wiadomość w sprawie: " + listingTitle,
|
||||||
|
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
@@ -105,8 +143,10 @@ public class AccountEmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String greeting(String name) {
|
// Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
|
||||||
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
|
// escapowane - inaczej prawdziwy mail z serwisu niosl by link lub tresc atakujacego.
|
||||||
|
static String greeting(String name) {
|
||||||
|
String who = name == null || name.isBlank() ? "" : " " + HtmlUtils.htmlEscape(name.trim().split("\\s+")[0]);
|
||||||
return "<p>Cześć" + who + ",</p>";
|
return "<p>Cześć" + who + ",</p>";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +156,7 @@ public class AccountEmailService {
|
|||||||
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
|
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
|
||||||
// Naglowek z logo + nazwa
|
// Naglowek z logo + nazwa
|
||||||
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
|
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
|
||||||
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\""
|
+ "<img src=\"cid:" + EmailSender.LOGO_CID + "\" width=\"34\" height=\"34\" alt=\"\""
|
||||||
+ " style=\"vertical-align:middle;border-radius:8px;\">"
|
+ " style=\"vertical-align:middle;border-radius:8px;\">"
|
||||||
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
|
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
|
||||||
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
|
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import pl.polskalokalnie.auth.dto.AuthResponse;
|
|||||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
|
||||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||||
|
|
||||||
@@ -47,11 +46,6 @@ public class AuthController {
|
|||||||
return authService.login(request);
|
return authService.login(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/social")
|
|
||||||
public AuthResponse social(@Valid @RequestBody SocialLoginRequest request) {
|
|
||||||
return authService.socialLogin(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Aktywacja konta ---
|
// --- Aktywacja konta ---
|
||||||
|
|
||||||
@PostMapping("/activate")
|
@PostMapping("/activate")
|
||||||
@@ -118,10 +112,10 @@ public class AuthController {
|
|||||||
|
|
||||||
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
|
||||||
|
|
||||||
|
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
|
||||||
@PostMapping("/change-password")
|
@PostMapping("/change-password")
|
||||||
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
|
||||||
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package pl.polskalokalnie.auth;
|
|||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -14,14 +12,15 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.lead.LeadSyncService;
|
import pl.polskalokalnie.lead.LeadSyncService;
|
||||||
|
import pl.polskalokalnie.listing.ListingService;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||||
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
import pl.polskalokalnie.auth.dto.RegisterResponse;
|
||||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
|
||||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||||
|
import pl.polskalokalnie.user.AccountErasureService;
|
||||||
import pl.polskalokalnie.user.AccountType;
|
import pl.polskalokalnie.user.AccountType;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.AuthProvider;
|
import pl.polskalokalnie.user.AuthProvider;
|
||||||
@@ -53,6 +52,8 @@ public class AuthService {
|
|||||||
private final PhoneVerificationService phoneVerificationService;
|
private final PhoneVerificationService phoneVerificationService;
|
||||||
private final NickService nickService;
|
private final NickService nickService;
|
||||||
private final UserSettingsService userSettingsService;
|
private final UserSettingsService userSettingsService;
|
||||||
|
private final AccountErasureService accountErasureService;
|
||||||
|
private final ListingService listingService;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
@@ -65,7 +66,9 @@ public class AuthService {
|
|||||||
AccountEmailService accountEmailService,
|
AccountEmailService accountEmailService,
|
||||||
PhoneVerificationService phoneVerificationService,
|
PhoneVerificationService phoneVerificationService,
|
||||||
NickService nickService,
|
NickService nickService,
|
||||||
UserSettingsService userSettingsService
|
UserSettingsService userSettingsService,
|
||||||
|
AccountErasureService accountErasureService,
|
||||||
|
ListingService listingService
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.blockedEmailRepository = blockedEmailRepository;
|
this.blockedEmailRepository = blockedEmailRepository;
|
||||||
@@ -78,6 +81,8 @@ public class AuthService {
|
|||||||
this.phoneVerificationService = phoneVerificationService;
|
this.phoneVerificationService = phoneVerificationService;
|
||||||
this.nickService = nickService;
|
this.nickService = nickService;
|
||||||
this.userSettingsService = userSettingsService;
|
this.userSettingsService = userSettingsService;
|
||||||
|
this.accountErasureService = accountErasureService;
|
||||||
|
this.listingService = listingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -105,7 +110,14 @@ public class AuthService {
|
|||||||
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
|
||||||
user.setPhone(phone);
|
user.setPhone(phone);
|
||||||
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
|
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
|
||||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
// Adres zapisujemy tylko firmom - konto prywatne go nie potrzebuje (minimalizacja danych).
|
||||||
|
if (user.getAccountType() == AccountType.COMPANY) {
|
||||||
|
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||||
|
}
|
||||||
|
// Rejestracja jest mozliwa tylko po zaznaczeniu zgody w formularzu - zapisujemy jej moment.
|
||||||
|
if (Boolean.TRUE.equals(request.acceptTerms())) {
|
||||||
|
user.setTermsAcceptedAt(Instant.now());
|
||||||
|
}
|
||||||
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
|
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
|
||||||
user.setVerified(false);
|
user.setVerified(false);
|
||||||
user.setPhoneVerified(false);
|
user.setPhoneVerified(false);
|
||||||
@@ -145,6 +157,8 @@ public class AuthService {
|
|||||||
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.setLastActiveAt(Instant.now());
|
||||||
|
userRepository.save(user);
|
||||||
return buildAuthResponse(user);
|
return buildAuthResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +204,8 @@ public class AuthService {
|
|||||||
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
|
||||||
|
user.revokeSessions();
|
||||||
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
|
||||||
user.setVerified(true);
|
user.setVerified(true);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
@@ -209,8 +225,12 @@ public class AuthService {
|
|||||||
*/
|
*/
|
||||||
public void resendPhoneOtp(String email) {
|
public void resendPhoneOtp(String email) {
|
||||||
String normalized = normalizeEmail(email);
|
String normalized = normalizeEmail(email);
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
|
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
// zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
|
||||||
|
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
|
||||||
|
if (user == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
if (user.getPhone() == null || user.getPhone().isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
|
||||||
}
|
}
|
||||||
@@ -259,49 +279,20 @@ public class AuthService {
|
|||||||
return authToken;
|
return authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Odstep, po ktorym wejscie do aplikacji (odswiezenie /auth/me) na nowo odnotowuje aktywnosc.
|
||||||
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
|
// Bez tego zapisywalibysmy do bazy przy kazdym zadaniu profilu - a wystarczy zgrubna dokladnosc.
|
||||||
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
|
private static final Duration LAST_ACTIVE_REFRESH = Duration.ofMinutes(2);
|
||||||
*/
|
|
||||||
public AuthResponse socialLogin(SocialLoginRequest request) {
|
|
||||||
AuthProvider provider = request.provider();
|
|
||||||
String email = normalizeEmail(
|
|
||||||
request.email() != null && !request.email().isBlank()
|
|
||||||
? request.email()
|
|
||||||
: defaultSocialEmail(provider));
|
|
||||||
String name = request.fullName() != null && !request.fullName().isBlank()
|
|
||||||
? request.fullName().trim()
|
|
||||||
: defaultSocialName(provider);
|
|
||||||
|
|
||||||
textModerationService.validateOrThrow(name);
|
|
||||||
|
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(email).orElseGet(() -> {
|
|
||||||
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
|
|
||||||
}
|
|
||||||
AppUser created = new AppUser();
|
|
||||||
created.setEmail(email);
|
|
||||||
created.setFullName(name);
|
|
||||||
created.setNick(nickService.generateFromName(name, email, null));
|
|
||||||
created.setRole(Role.USER);
|
|
||||||
created.setProvider(provider);
|
|
||||||
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
|
|
||||||
created.setVerified(true);
|
|
||||||
return userRepository.save(created);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user.isBlocked()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
|
||||||
}
|
|
||||||
|
|
||||||
leadSyncService.syncUser(user);
|
|
||||||
return buildAuthResponse(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public UserResponse currentUser(String email) {
|
public UserResponse currentUser(String email) {
|
||||||
return userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.map(this::withAvatar)
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (user.getLastActiveAt() == null || user.getLastActiveAt().isBefore(now.minus(LAST_ACTIVE_REFRESH))) {
|
||||||
|
user.setLastActiveAt(now);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
return withAvatar(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,7 +308,7 @@ public class AuthService {
|
|||||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
|
||||||
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address());
|
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.address());
|
||||||
|
|
||||||
user.setFullName(request.fullName().trim());
|
user.setFullName(request.fullName().trim());
|
||||||
if (request.nick() != null && !request.nick().isBlank()) {
|
if (request.nick() != null && !request.nick().isBlank()) {
|
||||||
@@ -331,18 +322,20 @@ public class AuthService {
|
|||||||
user.setPhoneVerified(false);
|
user.setPhoneVerified(false);
|
||||||
}
|
}
|
||||||
user.setPhone(phone);
|
user.setPhone(phone);
|
||||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
// Adres przyjmujemy wylacznie od kont firmowych (siedziba przedsiebiorcy). Konto prywatne
|
||||||
|
// nie ma po co podawac adresu zamieszkania, wiec nawet przyslany z zewnatrz zostaje odrzucony
|
||||||
|
// i kasuje to, co ewentualnie zapisano wczesniej.
|
||||||
|
if (user.getAccountType() == AccountType.COMPANY) {
|
||||||
|
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||||
|
} else {
|
||||||
|
user.setAddress(null);
|
||||||
|
}
|
||||||
|
// NIP: null = nie zmieniaj (zapis czesciowy nie moze skasowac NIP-u firmy), pusty = wyczysc.
|
||||||
|
if (request.nip() != null) {
|
||||||
|
user.setNip(request.nip().isBlank() ? null : request.nip().trim());
|
||||||
|
}
|
||||||
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
||||||
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
||||||
if (request.birthDate() != null && !request.birthDate().isBlank()) {
|
|
||||||
try {
|
|
||||||
user.setBirthDate(LocalDate.parse(request.birthDate().trim()));
|
|
||||||
} catch (DateTimeParseException ex) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.setBirthDate(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
AppUser saved = userRepository.save(user);
|
AppUser saved = userRepository.save(user);
|
||||||
leadSyncService.syncUser(saved);
|
leadSyncService.syncUser(saved);
|
||||||
@@ -354,7 +347,7 @@ public class AuthService {
|
|||||||
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
* wiec przejeta sesja nie pozwala przejac konta na stale.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void changePassword(String email, String currentPassword, String newPassword) {
|
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||||
|
|
||||||
@@ -381,6 +374,8 @@ public class AuthService {
|
|||||||
|
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
user.setPasswordChangedAt(Instant.now());
|
user.setPasswordChangedAt(Instant.now());
|
||||||
|
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
|
||||||
|
user.revokeSessions();
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
|
||||||
@@ -390,6 +385,7 @@ public class AuthService {
|
|||||||
authTokenRepository.save(old);
|
authTokenRepository.save(old);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return buildAuthResponse(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,7 +449,9 @@ public class AuthService {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountErasureService.eraseFor(user.getId(), user.getEmail());
|
||||||
userSettingsService.deleteForUser(user.getId());
|
userSettingsService.deleteForUser(user.getId());
|
||||||
|
listingService.deleteAllOfOwner(user.getEmail());
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,20 +473,4 @@ public class AuthService {
|
|||||||
String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName();
|
String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName();
|
||||||
return nickService.generateFromName(source, user.getEmail(), user);
|
return nickService.generateFromName(source, user.getEmail(), user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String defaultSocialEmail(AuthProvider provider) {
|
|
||||||
return switch (provider) {
|
|
||||||
case GOOGLE -> "demo.google@gmail.com";
|
|
||||||
case FACEBOOK -> "demo.facebook@facebook.com";
|
|
||||||
case LOCAL -> "demo.local@polskalokalnie.pl";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String defaultSocialName(AuthProvider provider) {
|
|
||||||
return switch (provider) {
|
|
||||||
case GOOGLE -> "Użytkownik Google";
|
|
||||||
case FACEBOOK -> "Użytkownik Facebook";
|
|
||||||
case LOCAL -> "Użytkownik";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pl.polskalokalnie.auth;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprzatanie jednorazowych tokenow i kodow SMS. Wczesniej zostawaly w bazie bezterminowo,
|
||||||
|
* mimo ze po wygasnieciu sa bezuzyteczne - a wiersz phone_otps zawiera numer telefonu
|
||||||
|
* i kod w jawnej postaci. Zasada ograniczenia przechowywania (art. 5 ust. 1 lit. e RODO)
|
||||||
|
* wymaga, zeby znikaly, gdy przestaja byc potrzebne.
|
||||||
|
*
|
||||||
|
* <p>Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje
|
||||||
|
* czytelny komunikat zamiast bledu "nie znaleziono".</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ExpiredTokenCleaner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleaner.class);
|
||||||
|
|
||||||
|
/** Bufor po wygasnieciu - tyle jeszcze trzymamy zuzyty albo przeterminowany wpis. */
|
||||||
|
private static final int GRACE_DAYS = 7;
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ExpiredTokenCleaner(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 15 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void cleanDaily() {
|
||||||
|
Instant cutoff = Instant.now().minus(GRACE_DAYS, ChronoUnit.DAYS);
|
||||||
|
try {
|
||||||
|
int tokens = jdbcTemplate.update("DELETE FROM auth_tokens WHERE expires_at < ?", cutoff);
|
||||||
|
int otps = jdbcTemplate.update("DELETE FROM phone_otps WHERE expires_at < ?", cutoff);
|
||||||
|
if (tokens > 0 || otps > 0) {
|
||||||
|
log.info("Sprzatanie: usunieto {} wygaslych tokenow i {} kodow SMS", tokens, otps);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Sprzatanie wygaslych tokenow nie powiodlo sie: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -14,14 +15,20 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
|||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import pl.polskalokalnie.user.AppUser;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
private final JwtService jwtService;
|
static final String STREAM_PATH = "/api/notifications/stream";
|
||||||
|
|
||||||
public JwtAuthFilter(JwtService jwtService) {
|
private final JwtService jwtService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -30,16 +37,29 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
@NonNull HttpServletResponse response,
|
@NonNull HttpServletResponse response,
|
||||||
@NonNull FilterChain filterChain
|
@NonNull FilterChain filterChain
|
||||||
) throws ServletException, IOException {
|
) throws ServletException, IOException {
|
||||||
String token = resolveToken(request);
|
String header = request.getHeader("Authorization");
|
||||||
if (token != null) {
|
boolean fromHeader = header != null && header.startsWith("Bearer ");
|
||||||
|
// Token w adresie tylko dla strumienia powiadomien (EventSource nie ustawia naglowkow) i tylko
|
||||||
|
// krotki token strumienia - token sesji w adresie trafial do logow serwera.
|
||||||
|
String token = fromHeader ? header.substring(7)
|
||||||
|
: STREAM_PATH.equals(request.getRequestURI()) ? request.getParameter("ticket") : null;
|
||||||
|
if (token != null && !token.isBlank()) {
|
||||||
try {
|
try {
|
||||||
Claims claims = jwtService.parse(token);
|
Claims claims = jwtService.parse(token);
|
||||||
String email = claims.getSubject();
|
boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
|
||||||
String role = claims.get("role", String.class);
|
if (streamToken == fromHeader) {
|
||||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
// Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
|
||||||
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
|
throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
}
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
|
||||||
|
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
|
||||||
|
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
|
||||||
|
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
|
||||||
|
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
|
||||||
|
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
|
||||||
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
|
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
|
||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
@@ -48,16 +68,12 @@ public class JwtAuthFilter extends OncePerRequestFilter {
|
|||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
|
static boolean isSessionValid(AppUser user, Instant issuedAt) {
|
||||||
private String resolveToken(HttpServletRequest request) {
|
if (user.isBlocked() || user.getRole() == null) {
|
||||||
String header = request.getHeader("Authorization");
|
return false;
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
|
||||||
return header.substring(7);
|
|
||||||
}
|
}
|
||||||
String param = request.getParameter("access_token");
|
Instant validFrom = user.getSessionsValidFrom();
|
||||||
if (param != null && !param.isBlank()) {
|
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
|
||||||
return param;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ public class JwtService {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Przeznaczenie tokenu strumienia powiadomien (claim "purpose"). */
|
||||||
|
public static final String PURPOSE_STREAM = "sse";
|
||||||
|
private static final long STREAM_TOKEN_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Krotki (60 s) token wylacznie do otwarcia strumienia powiadomien. EventSource nie ustawia
|
||||||
|
* naglowkow, wiec token idzie w adresie - a adresy laduja w logach. Dlatego nie jest to token
|
||||||
|
* sesji: dziala tylko na /api/notifications/stream i wygasa po minucie.
|
||||||
|
*/
|
||||||
|
public String generateStreamToken(String email) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(email)
|
||||||
|
.claim("purpose", PURPOSE_STREAM)
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plusSeconds(STREAM_TOKEN_SECONDS)))
|
||||||
|
.signWith(key)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
public Claims parse(String token) {
|
public Claims parse(String token) {
|
||||||
return Jwts.parser()
|
return Jwts.parser()
|
||||||
.verifyWith(key)
|
.verifyWith(key)
|
||||||
|
|||||||
@@ -35,18 +35,28 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/error").permitAll()
|
.requestMatchers("/error").permitAll()
|
||||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
|
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||||
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
|
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
|
||||||
"/api/auth/forgot-password", "/api/auth/reset-password",
|
"/api/auth/forgot-password", "/api/auth/reset-password",
|
||||||
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
|
||||||
.requestMatchers("/api/i18n/translate").permitAll()
|
|
||||||
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
|
||||||
|
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
|
||||||
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
|
||||||
.requestMatchers("/api/auth/me").authenticated()
|
.requestMatchers("/api/auth/me").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
// Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
|
||||||
|
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
|
||||||
|
"/api/listings/favorites").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||||
|
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
|
||||||
|
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
|
||||||
|
// Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import pl.polskalokalnie.user.AccountType;
|
|||||||
|
|
||||||
public record RegisterRequest(
|
public record RegisterRequest(
|
||||||
@NotBlank @Email String email,
|
@NotBlank @Email String email,
|
||||||
@NotBlank @Size(min = 6, max = 72) String password,
|
@NotBlank @Size(min = 8, max = 72) String password,
|
||||||
@NotBlank String fullName,
|
@NotBlank @Size(max = 120) String fullName,
|
||||||
AccountType accountType,
|
AccountType accountType,
|
||||||
String phone,
|
String phone,
|
||||||
String nip,
|
String nip,
|
||||||
String address
|
String address,
|
||||||
|
/** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */
|
||||||
|
Boolean acceptTerms
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package pl.polskalokalnie.auth.dto;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import pl.polskalokalnie.user.AuthProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Symulowane logowanie spoleczne. W wersji produkcyjnej email/name pochodzilyby
|
|
||||||
* z tokenu dostawcy OAuth (Google/Facebook); tutaj przychodza z frontendu jako dane demo.
|
|
||||||
*/
|
|
||||||
public record SocialLoginRequest(
|
|
||||||
@NotNull AuthProvider provider,
|
|
||||||
String email,
|
|
||||||
String fullName
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
package pl.polskalokalnie.auth.dto;
|
package pl.polskalokalnie.auth.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import pl.polskalokalnie.user.ContactPreference;
|
import pl.polskalokalnie.user.ContactPreference;
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
import pl.polskalokalnie.user.PreferredLanguage;
|
||||||
|
|
||||||
public record UpdateProfileRequest(
|
public record UpdateProfileRequest(
|
||||||
@NotBlank String fullName,
|
@NotBlank @Size(max = 120) String fullName,
|
||||||
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
|
||||||
String nick,
|
String nick,
|
||||||
String phone,
|
String phone,
|
||||||
String birthDate,
|
// Adres siedziby - przyjmowany tylko od kont firmowych.
|
||||||
String address,
|
String address,
|
||||||
|
// NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u);
|
||||||
|
// pusty ciag = wyczysc. Dotyczy kont firmowych.
|
||||||
|
String nip,
|
||||||
ContactPreference contactPreference,
|
ContactPreference contactPreference,
|
||||||
PreferredLanguage preferredLanguage
|
PreferredLanguage preferredLanguage
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package pl.polskalokalnie.auth.dto;
|
package pl.polskalokalnie.auth.dto;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import pl.polskalokalnie.user.AccountType;
|
import pl.polskalokalnie.user.AccountType;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.AuthProvider;
|
import pl.polskalokalnie.user.AuthProvider;
|
||||||
@@ -22,7 +21,6 @@ public record UserResponse(
|
|||||||
ContactPreference contactPreference,
|
ContactPreference contactPreference,
|
||||||
PreferredLanguage preferredLanguage,
|
PreferredLanguage preferredLanguage,
|
||||||
String nip,
|
String nip,
|
||||||
LocalDate birthDate,
|
|
||||||
boolean verified,
|
boolean verified,
|
||||||
boolean phoneVerified,
|
boolean phoneVerified,
|
||||||
boolean blocked,
|
boolean blocked,
|
||||||
@@ -50,7 +48,6 @@ public record UserResponse(
|
|||||||
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
|
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
|
||||||
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
|
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
|
||||||
user.getNip(),
|
user.getNip(),
|
||||||
user.getBirthDate(),
|
|
||||||
user.isVerified(),
|
user.isVerified(),
|
||||||
user.isPhoneVerified(),
|
user.isPhoneVerified(),
|
||||||
user.isBlocked(),
|
user.isBlocked(),
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package pl.polskalokalnie.campaign;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Limit;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
import pl.polskalokalnie.lead.Lead;
|
||||||
|
import pl.polskalokalnie.lead.LeadRepository;
|
||||||
|
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||||
|
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||||
|
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||||
|
import pl.polskalokalnie.send.EmailSender;
|
||||||
|
import pl.polskalokalnie.send.SendResult;
|
||||||
|
import pl.polskalokalnie.send.SmsSender;
|
||||||
|
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
|
||||||
|
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
|
||||||
|
*
|
||||||
|
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
|
||||||
|
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CampaignSender {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
|
||||||
|
|
||||||
|
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
|
||||||
|
private static final int BATCH_SIZE = 25;
|
||||||
|
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final CampaignRecipientRepository recipientRepository;
|
||||||
|
private final LeadRepository leadRepository;
|
||||||
|
private final MailConfigService mailConfigService;
|
||||||
|
private final EmailSender emailSender;
|
||||||
|
private final SmsSender smsSender;
|
||||||
|
private final UnsubscribeService unsubscribeService;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public CampaignSender(CampaignRepository campaignRepository,
|
||||||
|
CampaignRecipientRepository recipientRepository,
|
||||||
|
LeadRepository leadRepository,
|
||||||
|
MailConfigService mailConfigService,
|
||||||
|
EmailSender emailSender,
|
||||||
|
SmsSender smsSender,
|
||||||
|
UnsubscribeService unsubscribeService,
|
||||||
|
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.recipientRepository = recipientRepository;
|
||||||
|
this.leadRepository = leadRepository;
|
||||||
|
this.mailConfigService = mailConfigService;
|
||||||
|
this.emailSender = emailSender;
|
||||||
|
this.smsSender = smsSender;
|
||||||
|
this.unsubscribeService = unsubscribeService;
|
||||||
|
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
|
||||||
|
@Transactional
|
||||||
|
public void processOutbox() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
|
||||||
|
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
|
||||||
|
.forEach(campaign -> {
|
||||||
|
campaign.setStatus(CampaignStatus.SENDING);
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
|
||||||
|
try {
|
||||||
|
sendBatch(campaign);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendBatch(Campaign campaign) {
|
||||||
|
int remainingToday = remainingToday(campaign);
|
||||||
|
if (remainingToday <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
|
||||||
|
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
|
||||||
|
if (batch.isEmpty()) {
|
||||||
|
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
|
||||||
|
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
|
||||||
|
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
|
||||||
|
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
|
||||||
|
if (!senderConfigured(smtp, sms)) {
|
||||||
|
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int sent = 0;
|
||||||
|
|
||||||
|
for (CampaignRecipient recipient : batch) {
|
||||||
|
if (stillOptedOut(recipient)) {
|
||||||
|
recipient.setStatus(RecipientStatus.OPTED_OUT);
|
||||||
|
recipientRepository.save(recipient);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SendResult result = campaign.getChannel() == Channel.EMAIL
|
||||||
|
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
|
||||||
|
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
|
||||||
|
if (result.ok()) {
|
||||||
|
recipient.setStatus(RecipientStatus.SENT);
|
||||||
|
recipient.setSentAt(Instant.now());
|
||||||
|
recipient.setProviderMessageId(result.providerMessageId());
|
||||||
|
recipient.setError(null);
|
||||||
|
sent++;
|
||||||
|
} else {
|
||||||
|
recipient.setStatus(RecipientStatus.FAILED);
|
||||||
|
recipient.setError(trim(result.error()));
|
||||||
|
}
|
||||||
|
recipientRepository.save(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
campaign.setSentToday(todaysCount(campaign) + sent);
|
||||||
|
campaign.setSentTodayDate(LocalDate.now());
|
||||||
|
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
|
||||||
|
if (smtp != null) {
|
||||||
|
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
|
||||||
|
}
|
||||||
|
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
|
||||||
|
private int remainingToday(Campaign campaign) {
|
||||||
|
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
|
||||||
|
return limit - todaysCount(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int todaysCount(Campaign campaign) {
|
||||||
|
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
|
||||||
|
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
|
||||||
|
private boolean stillOptedOut(CampaignRecipient recipient) {
|
||||||
|
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
|
||||||
|
if (lead == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return recipient.getChannel() == Channel.EMAIL
|
||||||
|
? !lead.isEmailConsent() || lead.isEmailOptOut()
|
||||||
|
: !lead.isSmsConsent() || lead.isSmsOptOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
|
||||||
|
private String unsubscribeLink(CampaignRecipient recipient) {
|
||||||
|
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
|
||||||
|
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
|
||||||
|
String body = campaign.getBody() == null ? "" : campaign.getBody();
|
||||||
|
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
|
||||||
|
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
|
||||||
|
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||||
|
String footer = link == null ? ""
|
||||||
|
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
|
||||||
|
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
|
||||||
|
+ " informacje marketingowe od serwisu Polska Lokalnie. "
|
||||||
|
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
|
||||||
|
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
|
||||||
|
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
|
||||||
|
+ greeting + body + footer + "</div></body></html>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
|
||||||
|
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
|
||||||
|
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||||
|
return link == null ? body : body + " Rezygnacja: " + link;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() > 240 ? value.substring(0, 240) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,8 +85,8 @@ public class CampaignService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
|
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
|
||||||
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
|
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
|
||||||
* wysylajacego dodajemy w kolejnej fazie.
|
* Wysylke z outboxu obsluguje {@link CampaignSender}.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public CampaignResponse schedule(Long id) {
|
public CampaignResponse schedule(Long id) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package pl.polskalokalnie.config;
|
package pl.polskalokalnie.config;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.CommandLineRunner;
|
import org.springframework.boot.CommandLineRunner;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -8,11 +10,6 @@ import org.springframework.stereotype.Component;
|
|||||||
import pl.polskalokalnie.banner.Banner;
|
import pl.polskalokalnie.banner.Banner;
|
||||||
import pl.polskalokalnie.banner.BannerRepository;
|
import pl.polskalokalnie.banner.BannerRepository;
|
||||||
import pl.polskalokalnie.banner.BannerSlot;
|
import pl.polskalokalnie.banner.BannerSlot;
|
||||||
import pl.polskalokalnie.listing.ListingRepository;
|
|
||||||
import pl.polskalokalnie.listing.ListingStatus;
|
|
||||||
import pl.polskalokalnie.listing.OfferType;
|
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
|
||||||
import pl.polskalokalnie.listing.PropertyType;
|
|
||||||
import pl.polskalokalnie.promotion.PromotionPackage;
|
import pl.polskalokalnie.promotion.PromotionPackage;
|
||||||
import pl.polskalokalnie.promotion.PromotionPackageRepository;
|
import pl.polskalokalnie.promotion.PromotionPackageRepository;
|
||||||
import pl.polskalokalnie.promotion.PromotionPlan;
|
import pl.polskalokalnie.promotion.PromotionPlan;
|
||||||
@@ -26,8 +23,9 @@ import pl.polskalokalnie.user.UserRepository;
|
|||||||
@Component
|
@Component
|
||||||
public class DataSeeder implements CommandLineRunner {
|
public class DataSeeder implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DataSeeder.class);
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final ListingRepository listingRepository;
|
|
||||||
private final BannerRepository bannerRepository;
|
private final BannerRepository bannerRepository;
|
||||||
private final PromotionPlanRepository promotionPlanRepository;
|
private final PromotionPlanRepository promotionPlanRepository;
|
||||||
private final PromotionPackageRepository promotionPackageRepository;
|
private final PromotionPackageRepository promotionPackageRepository;
|
||||||
@@ -36,21 +34,21 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
private final String adminEmail;
|
private final String adminEmail;
|
||||||
private final String adminPassword;
|
private final String adminPassword;
|
||||||
private final String adminName;
|
private final String adminName;
|
||||||
|
private final boolean adminResetPassword;
|
||||||
|
|
||||||
public DataSeeder(
|
public DataSeeder(
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
ListingRepository listingRepository,
|
|
||||||
BannerRepository bannerRepository,
|
BannerRepository bannerRepository,
|
||||||
PromotionPlanRepository promotionPlanRepository,
|
PromotionPlanRepository promotionPlanRepository,
|
||||||
PromotionPackageRepository promotionPackageRepository,
|
PromotionPackageRepository promotionPackageRepository,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
NickService nickService,
|
NickService nickService,
|
||||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
@Value("${app.admin.email:admin@softspm.com}") String adminEmail,
|
||||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||||
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName
|
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName,
|
||||||
|
@Value("${app.admin.reset-password:false}") boolean adminResetPassword
|
||||||
) {
|
) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.listingRepository = listingRepository;
|
|
||||||
this.bannerRepository = bannerRepository;
|
this.bannerRepository = bannerRepository;
|
||||||
this.promotionPlanRepository = promotionPlanRepository;
|
this.promotionPlanRepository = promotionPlanRepository;
|
||||||
this.promotionPackageRepository = promotionPackageRepository;
|
this.promotionPackageRepository = promotionPackageRepository;
|
||||||
@@ -59,12 +57,14 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
this.adminEmail = adminEmail;
|
this.adminEmail = adminEmail;
|
||||||
this.adminPassword = adminPassword;
|
this.adminPassword = adminPassword;
|
||||||
this.adminName = adminName;
|
this.adminName = adminName;
|
||||||
|
this.adminResetPassword = adminResetPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(String... args) {
|
public void run(String... args) {
|
||||||
seedAdmin();
|
seedAdmin();
|
||||||
seedDemoListings();
|
// Bez ogloszen demo: seeder dodawal je przy kazdym starcie z pusta tabela ogloszen,
|
||||||
|
// wiec po wyczyszczeniu danych testowych na produkcji wracalyby przy kolejnym wdrozeniu.
|
||||||
seedBanners();
|
seedBanners();
|
||||||
seedPromotion();
|
seedPromotion();
|
||||||
}
|
}
|
||||||
@@ -135,57 +135,57 @@ public class DataSeeder implements CommandLineRunner {
|
|||||||
return banner;
|
return banner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konto administratora z konfiguracji (APP_ADMIN_EMAIL / APP_ADMIN_PASSWORD).
|
||||||
|
*
|
||||||
|
* Gdy konta o tym adresie jeszcze nie ma - zakladamy je. Gdy juz istnieje, haslo z konfiguracji
|
||||||
|
* jest ignorowane, bo inaczej kazdy restart cofalby haslo zmienione w ustawieniach konta.
|
||||||
|
* Swiadome nadpisanie wlacza APP_ADMIN_RESET_PASSWORD=true - to jedyna droga odzyskania dostepu,
|
||||||
|
* gdy haslo administratora zostalo zgubione.
|
||||||
|
*/
|
||||||
private void seedAdmin() {
|
private void seedAdmin() {
|
||||||
if (userRepository.existsByEmailIgnoreCase(adminEmail)) {
|
String email = adminEmail.trim().toLowerCase();
|
||||||
|
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
|
||||||
|
|
||||||
|
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
|
||||||
|
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
|
||||||
|
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
if (passwordMissing) {
|
||||||
|
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppUser admin = new AppUser();
|
||||||
|
admin.setEmail(email);
|
||||||
|
admin.setFullName(adminName);
|
||||||
|
admin.setNick(nickService.generateUnique(email.split("@")[0]));
|
||||||
|
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
||||||
|
admin.setRole(Role.ADMIN);
|
||||||
|
admin.setProvider(AuthProvider.LOCAL);
|
||||||
|
admin.setVerified(true);
|
||||||
|
userRepository.save(admin);
|
||||||
|
log.info("Utworzono konto administratora {}", email);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AppUser admin = new AppUser();
|
|
||||||
admin.setEmail(adminEmail.toLowerCase());
|
|
||||||
admin.setFullName(adminName);
|
|
||||||
admin.setNick(nickService.generateUnique(adminEmail.split("@")[0]));
|
|
||||||
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
|
||||||
admin.setRole(Role.ADMIN);
|
|
||||||
admin.setProvider(AuthProvider.LOCAL);
|
|
||||||
admin.setVerified(true);
|
|
||||||
userRepository.save(admin);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void seedDemoListings() {
|
if (!adminResetPassword) {
|
||||||
if (listingRepository.count() > 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listingRepository.save(demoListing(
|
if (passwordMissing) {
|
||||||
"Słoneczne 3 pokoje na Mokotowie", "Rozkładowe mieszkanie po remoncie, blisko metra.",
|
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
|
||||||
OfferType.SALE, PropertyType.APARTMENT, "Warszawa", "ul. Puławska 120",
|
return;
|
||||||
new BigDecimal("749000"), 62.0, 3, ListingStatus.APPROVED));
|
}
|
||||||
listingRepository.save(demoListing(
|
|
||||||
"Dom z ogrodem pod Krakowem", "Wolnostojący dom, działka 600 m², cicha okolica.",
|
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
|
||||||
OfferType.SALE, PropertyType.HOUSE, "Kraków", "ul. Podgórska 8",
|
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
|
||||||
new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING));
|
existing.setPasswordHash(passwordEncoder.encode(adminPassword));
|
||||||
listingRepository.save(demoListing(
|
existing.setRole(Role.ADMIN);
|
||||||
"Kawalerka do wynajęcia - Wrocław", "Umeblowana kawalerka w centrum, dostępna od zaraz.",
|
existing.setVerified(true);
|
||||||
OfferType.RENT, PropertyType.APARTMENT, "Wrocław", "ul. Krupnicza 3",
|
existing.setBlocked(false);
|
||||||
new BigDecimal("2600"), 30.0, 1, ListingStatus.PENDING));
|
userRepository.save(existing);
|
||||||
|
log.warn("APP_ADMIN_RESET_PASSWORD=true - ustawiono haslo z konfiguracji dla konta {}."
|
||||||
|
+ " Wylacz te zmienna po odzyskaniu dostepu.", email);
|
||||||
}
|
}
|
||||||
|
|
||||||
private PropertyListing demoListing(
|
|
||||||
String title, String description, OfferType offerType, PropertyType propertyType,
|
|
||||||
String city, String address, BigDecimal price, Double area, Integer rooms, ListingStatus status
|
|
||||||
) {
|
|
||||||
PropertyListing listing = new PropertyListing();
|
|
||||||
listing.setTitle(title);
|
|
||||||
listing.setDescription(description);
|
|
||||||
listing.setOfferType(offerType);
|
|
||||||
listing.setPropertyType(propertyType);
|
|
||||||
listing.setCity(city);
|
|
||||||
listing.setAddress(address);
|
|
||||||
listing.setPrice(price);
|
|
||||||
listing.setArea(area);
|
|
||||||
listing.setRooms(rooms);
|
|
||||||
listing.setContactName("Biuro Polska Lokalnie");
|
|
||||||
listing.setContactPhone("+48 600 100 200");
|
|
||||||
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
|
|
||||||
listing.setStatus(status);
|
|
||||||
return listing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package pl.polskalokalnie.config;
|
package pl.polskalokalnie.config;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -8,9 +13,32 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class SchemaFixer {
|
public class SchemaFixer {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SchemaFixer.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laty schematu pisane sa skladnia PostgreSQL (DO $$, to_regclass, lo_get, split_part...),
|
||||||
|
* bo taka baza dziala na serwerze. W testach schemat tworzy Hibernate z encji na H2,
|
||||||
|
* wiec nie ma czego latac - bez tego warunku kazdy @SpringBootTest wywracal sie przy starcie.
|
||||||
|
*/
|
||||||
|
private static boolean isPostgres(JdbcTemplate jdbcTemplate) {
|
||||||
|
DataSource dataSource = jdbcTemplate.getDataSource();
|
||||||
|
if (dataSource == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
return "PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName());
|
||||||
|
} catch (SQLException ex) {
|
||||||
|
log.warn("Nie udalo sie rozpoznac bazy danych, pomijam poprawki schematu: {}", ex.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
|
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
|
||||||
return args -> {
|
return args -> {
|
||||||
|
if (!isPostgres(jdbcTemplate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
jdbcTemplate.execute(
|
jdbcTemplate.execute(
|
||||||
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
|
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
"http://172.29.*.*:[*]",
|
"http://172.29.*.*:[*]",
|
||||||
"http://172.30.*.*:[*]",
|
"http://172.30.*.*:[*]",
|
||||||
"http://172.31.*.*:[*]",
|
"http://172.31.*.*:[*]",
|
||||||
"http://*.local:[*]")
|
"http://*.local:[*]",
|
||||||
|
"https://polskalokalnie.pl",
|
||||||
|
"https://www.polskalokalnie.pl")
|
||||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||||
.allowedHeaders("*");
|
.allowedHeaders("*");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,72 @@ public class GusService {
|
|||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||||
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
|
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
|
||||||
}
|
}
|
||||||
return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane));
|
|
||||||
|
String name = dane.get("Nazwa");
|
||||||
|
String regon = dane.get("Regon");
|
||||||
|
String address = buildAddress(dane);
|
||||||
|
|
||||||
|
// Wyszukiwanie (DaneSzukajPodmioty) zwraca skrocony rekord - dla czesci podmiotow (czesto
|
||||||
|
// jednoosobowych dzialalnosci) nazwa lub adres sa puste i sa dostepne dopiero w pelnym
|
||||||
|
// raporcie. Dociagamy go tylko wtedy, gdy czegos brakuje. Blad raportu nie moze zepsuc
|
||||||
|
// dzialajacych przypadkow, wiec zostajemy przy danych z wyszukiwania.
|
||||||
|
if ((isBlank(name) || isBlank(address)) && !isBlank(regon)) {
|
||||||
|
try {
|
||||||
|
Map<String, String> full = fullReport(endpoint, key, regon, dane.get("Typ"));
|
||||||
|
if (isBlank(name)) {
|
||||||
|
name = full.get("Nazwa");
|
||||||
|
}
|
||||||
|
if (isBlank(address)) {
|
||||||
|
String fullAddress = buildAddress(full);
|
||||||
|
if (!isBlank(fullAddress)) {
|
||||||
|
address = fullAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ResponseStatusException ignored) {
|
||||||
|
// Pelny raport jest tylko uzupelnieniem - brak odpowiedzi nie przerywa rejestracji.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GusCompanyResponse(name, nip, regon, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pelny raport podmiotu (DanePobierzPelnyRaport) - uzupelnia nazwe i adres, gdy nie ma ich w
|
||||||
|
* wyniku wyszukiwania. Nazwa raportu i nazwy pol zaleza od typu podmiotu (osoba prawna vs fizyczna).
|
||||||
|
* Zwrocone pola mapujemy na te same klucze co wyszukiwanie, zeby uzyc wspolnego buildAddress.
|
||||||
|
*/
|
||||||
|
private Map<String, String> fullReport(String endpoint, String key, String regon, String typ) {
|
||||||
|
boolean legal = typ != null && typ.trim().toUpperCase().startsWith("P");
|
||||||
|
String report = legal ? "BIR11OsPrawna" : "BIR11OsFizycznaDaneOgolne";
|
||||||
|
String prefix = legal ? "praw_" : "fiz_";
|
||||||
|
String body = """
|
||||||
|
<ns:DanePobierzPelnyRaport>
|
||||||
|
<ns:pRegon>%s</ns:pRegon>
|
||||||
|
<ns:pNazwaRaportu>%s</ns:pNazwaRaportu>
|
||||||
|
</ns:DanePobierzPelnyRaport>""".formatted(regon, report);
|
||||||
|
String response = call(endpoint, "DanePobierzPelnyRaport", body, session(endpoint, key));
|
||||||
|
Map<String, String> raw = firstDane(resultOf(response, "DanePobierzPelnyRaportResult"));
|
||||||
|
if (raw == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<String, String> mapped = new HashMap<>();
|
||||||
|
putIfPresent(mapped, "Nazwa", raw.get(prefix + "nazwa"));
|
||||||
|
putIfPresent(mapped, "Ulica", raw.get(prefix + "adSiedzUlica_Nazwa"));
|
||||||
|
putIfPresent(mapped, "NrNieruchomosci", raw.get(prefix + "adSiedzNumerNieruchomosci"));
|
||||||
|
putIfPresent(mapped, "NrLokalu", raw.get(prefix + "adSiedzNumerLokalu"));
|
||||||
|
putIfPresent(mapped, "KodPocztowy", raw.get(prefix + "adSiedzKodPocztowy"));
|
||||||
|
putIfPresent(mapped, "Miejscowosc", raw.get(prefix + "adSiedzMiejscowosc_Nazwa"));
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putIfPresent(Map<String, String> target, String key, String value) {
|
||||||
|
if (!isBlank(value)) {
|
||||||
|
target.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
|
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package pl.polskalokalnie.i18n;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class RuntimeTranslationService {
|
|
||||||
|
|
||||||
private static final int MAX_TEXTS_PER_REQUEST = 180;
|
|
||||||
private static final int MAX_TEXT_LENGTH = 400;
|
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(6))
|
|
||||||
.build();
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public Map<String, String> translateBatch(PreferredLanguage language, List<String> texts) {
|
|
||||||
if (texts == null || texts.isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia");
|
|
||||||
}
|
|
||||||
if (texts.size() > MAX_TEXTS_PER_REQUEST) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (language == PreferredLanguage.PL) {
|
|
||||||
Map<String, String> passthrough = new LinkedHashMap<>();
|
|
||||||
texts.forEach((text) -> passthrough.put(text, text));
|
|
||||||
return passthrough;
|
|
||||||
}
|
|
||||||
|
|
||||||
String targetCode = toGoogleLang(language);
|
|
||||||
Map<String, String> results = new LinkedHashMap<>();
|
|
||||||
List<String> toTranslate = new ArrayList<>();
|
|
||||||
|
|
||||||
for (String text : texts) {
|
|
||||||
String safeText = text != null ? text : "";
|
|
||||||
if (safeText.length() > MAX_TEXT_LENGTH) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia");
|
|
||||||
}
|
|
||||||
|
|
||||||
String cacheKey = targetCode + "|" + safeText;
|
|
||||||
String cached = cache.get(cacheKey);
|
|
||||||
if (cached != null) {
|
|
||||||
results.put(safeText, cached);
|
|
||||||
} else {
|
|
||||||
toTranslate.add(safeText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String text : toTranslate) {
|
|
||||||
String translated = translateSingle(text, targetCode);
|
|
||||||
cache.put(targetCode + "|" + text, translated);
|
|
||||||
results.put(text, translated);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String translateSingle(String source, String targetCode) {
|
|
||||||
if (source.isBlank()) {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8);
|
|
||||||
String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl="
|
|
||||||
+ targetCode + "&dt=t&q=" + encoded;
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
|
||||||
.GET()
|
|
||||||
.timeout(Duration.ofSeconds(10))
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.header("User-Agent", "PolskaLokalnie/1.0")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try {
|
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna");
|
|
||||||
}
|
|
||||||
return parseTranslatedText(response.body(), source);
|
|
||||||
} catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
|
||||||
} catch (IOException ex) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String parseTranslatedText(String payload, String fallback) throws IOException {
|
|
||||||
JsonNode root = objectMapper.readTree(payload);
|
|
||||||
JsonNode topSegments = root.path(0);
|
|
||||||
if (!topSegments.isArray() || topSegments.isEmpty()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
StringBuilder translated = new StringBuilder();
|
|
||||||
for (JsonNode segment : topSegments) {
|
|
||||||
JsonNode part = segment.path(0);
|
|
||||||
if (part.isTextual()) {
|
|
||||||
translated.append(part.asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String value = translated.toString();
|
|
||||||
return value.isBlank() ? fallback : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toGoogleLang(PreferredLanguage language) {
|
|
||||||
return switch (language) {
|
|
||||||
case EN -> "en";
|
|
||||||
case UK -> "uk";
|
|
||||||
case DE -> "de";
|
|
||||||
case PL -> "pl";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package pl.polskalokalnie.i18n;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import java.util.List;
|
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
|
||||||
|
|
||||||
public record TranslateRequest(
|
|
||||||
@NotNull PreferredLanguage language,
|
|
||||||
@NotEmpty List<String> texts
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package pl.polskalokalnie.i18n;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import pl.polskalokalnie.user.PreferredLanguage;
|
|
||||||
|
|
||||||
public record TranslateResponse(
|
|
||||||
PreferredLanguage language,
|
|
||||||
Map<String, String> translations
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package pl.polskalokalnie.i18n;
|
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/i18n")
|
|
||||||
public class TranslationController {
|
|
||||||
|
|
||||||
private final RuntimeTranslationService runtimeTranslationService;
|
|
||||||
|
|
||||||
public TranslationController(RuntimeTranslationService runtimeTranslationService) {
|
|
||||||
this.runtimeTranslationService = runtimeTranslationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/translate")
|
|
||||||
public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) {
|
|
||||||
List<String> normalizedTexts = request.texts().stream()
|
|
||||||
.map(text -> text == null ? "" : text)
|
|
||||||
.distinct()
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
|
|
||||||
return new TranslateResponse(request.language(), translations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,13 +67,17 @@ public class Lead {
|
|||||||
@Column(name = "tag", length = 60)
|
@Column(name = "tag", length = 60)
|
||||||
private List<String> tags = new ArrayList<>();
|
private List<String> tags = new ArrayList<>();
|
||||||
|
|
||||||
|
// Zgoda marketingowa musi byc wyrazna i uprzednia (art. 4 pkt 11 i art. 7 RODO oraz Prawo
|
||||||
|
// komunikacji elektronicznej), wiec domyslnie jej NIE MA. Wlacza ja sam uzytkownik
|
||||||
|
// w ustawieniach konta (MarketingConsentController) albo administrator przy imporcie leada,
|
||||||
|
// majac podstawe zebrana poza serwisem.
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@ColumnDefault("true")
|
@ColumnDefault("false")
|
||||||
private boolean emailConsent = true;
|
private boolean emailConsent = false;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@ColumnDefault("true")
|
@ColumnDefault("false")
|
||||||
private boolean smsConsent = true;
|
private boolean smsConsent = false;
|
||||||
|
|
||||||
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
|
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
|||||||
@@ -231,7 +231,11 @@ public class LeadService {
|
|||||||
lead.setPhone(phone);
|
lead.setPhone(phone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lead.setName(trimToNull(request.fullName()));
|
// Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
|
||||||
|
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
|
||||||
|
if (lead.getId() == null || lead.getName() == null) {
|
||||||
|
lead.setName(trimToNull(request.fullName()));
|
||||||
|
}
|
||||||
|
|
||||||
List<String> tags = new ArrayList<>(lead.getTags());
|
List<String> tags = new ArrayList<>(lead.getTags());
|
||||||
tags.add(ADVERTISING_TAG);
|
tags.add(ADVERTISING_TAG);
|
||||||
@@ -239,9 +243,12 @@ public class LeadService {
|
|||||||
lead.setTags(cleanTags(tags));
|
lead.setTags(cleanTags(tags));
|
||||||
|
|
||||||
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
|
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
|
||||||
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
|
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
|
||||||
lead.setEmailConsent(consent);
|
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
|
||||||
lead.setSmsConsent(consent);
|
if (lead.getId() == null) {
|
||||||
|
lead.setEmailConsent(consent);
|
||||||
|
lead.setSmsConsent(consent);
|
||||||
|
}
|
||||||
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
|
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
|
||||||
leadRepository.save(lead);
|
leadRepository.save(lead);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package pl.polskalokalnie.listing;
|
package pl.polskalokalnie.listing;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
public class ListingController {
|
public class ListingController {
|
||||||
|
|
||||||
private final ListingService listingService;
|
private final ListingService listingService;
|
||||||
|
private final ListingViewGuard viewGuard;
|
||||||
|
|
||||||
public ListingController(ListingService listingService) {
|
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
|
||||||
this.listingService = listingService;
|
this.listingService = listingService;
|
||||||
|
this.viewGuard = viewGuard;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +43,12 @@ public class ListingController {
|
|||||||
return listingService.search(city, offerType, propertyType, directOnly);
|
return listingService.search(city, offerType, propertyType, directOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
|
||||||
|
@GetMapping("/{id}/seller-listings")
|
||||||
|
public List<ListingResponse> sellerListings(@PathVariable Long id) {
|
||||||
|
return listingService.sellerListings(id);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/mine")
|
@GetMapping("/mine")
|
||||||
public List<ListingResponse> mine(Authentication authentication) {
|
public List<ListingResponse> mine(Authentication authentication) {
|
||||||
return listingService.findMine(authentication.getName());
|
return listingService.findMine(authentication.getName());
|
||||||
@@ -50,12 +59,16 @@ public class ListingController {
|
|||||||
public ListingDetailResponse getById(
|
public ListingDetailResponse getById(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestParam(defaultValue = "false") boolean incrementView,
|
@RequestParam(defaultValue = "false") boolean incrementView,
|
||||||
Authentication authentication
|
Authentication authentication,
|
||||||
|
HttpServletRequest request
|
||||||
) {
|
) {
|
||||||
String viewerEmail = authentication == null ? null : authentication.getName();
|
String viewerEmail = authentication == null ? null : authentication.getName();
|
||||||
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
|
||||||
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
|
||||||
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
|
// Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
|
||||||
|
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
|
||||||
|
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
|
||||||
|
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -69,7 +82,7 @@ public class ListingController {
|
|||||||
.body(response);
|
.body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
|
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ListingDetailResponse update(
|
public ListingDetailResponse update(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ public record ListingDetailResponse(
|
|||||||
Long ownerId,
|
Long ownerId,
|
||||||
// Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly.
|
// Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly.
|
||||||
String ownerAvatar,
|
String ownerAvatar,
|
||||||
|
// Typ konta wlasciciela ("PERSONAL"/"COMPANY") albo null, gdy ogloszenie nie ma konta w serwisie.
|
||||||
|
// Po tym front rozroznia oferte prywatna od firmowej (etykieta na karcie, jak na Otodom).
|
||||||
|
String ownerAccountType,
|
||||||
|
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
|
||||||
|
String ownerName,
|
||||||
|
// Data zalozenia konta wlasciciela - na karcie sprzedajacego pokazujemy "Na Polska Lokalnie od...".
|
||||||
|
Instant ownerCreatedAt,
|
||||||
|
// Ostatnia aktywnosc wlasciciela - na karcie sprzedajacego "Ostatnio aktywny/a...". Null = brak konta.
|
||||||
|
Instant ownerLastActiveAt,
|
||||||
ListingStatus status,
|
ListingStatus status,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
Long viewsCount,
|
Long viewsCount,
|
||||||
@@ -63,16 +72,22 @@ public record ListingDetailResponse(
|
|||||||
private static final double METERS_PER_DEGREE = 111_320d;
|
private static final double METERS_PER_DEGREE = 111_320d;
|
||||||
|
|
||||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
|
||||||
return from(listing, ownerId, ownerAvatar, false);
|
return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
|
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
|
||||||
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
|
||||||
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
|
* zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
|
||||||
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
|
* wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
|
||||||
|
*
|
||||||
|
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
|
||||||
|
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
|
||||||
|
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
|
||||||
*/
|
*/
|
||||||
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar, boolean masked) {
|
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
|
||||||
|
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
|
||||||
|
boolean masked, boolean publicView) {
|
||||||
String street = masked ? null : listing.getStreet();
|
String street = masked ? null : listing.getStreet();
|
||||||
String building = masked ? null : listing.getBuilding();
|
String building = masked ? null : listing.getBuilding();
|
||||||
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
String address = masked ? approximateAddress(listing) : listing.getAddress();
|
||||||
@@ -100,7 +115,7 @@ public record ListingDetailResponse(
|
|||||||
listing.getOwnership(),
|
listing.getOwnership(),
|
||||||
listing.getContactName(),
|
listing.getContactName(),
|
||||||
listing.getContactPhone(),
|
listing.getContactPhone(),
|
||||||
listing.getContactEmail(),
|
publicView ? null : listing.getContactEmail(),
|
||||||
listing.getRentExtra(),
|
listing.getRentExtra(),
|
||||||
listing.getAvailableFrom(),
|
listing.getAvailableFrom(),
|
||||||
listing.getFurnishing(),
|
listing.getFurnishing(),
|
||||||
@@ -115,9 +130,13 @@ public record ListingDetailResponse(
|
|||||||
List.copyOf(listing.getAmenities()),
|
List.copyOf(listing.getAmenities()),
|
||||||
List.copyOf(listing.getPhotos()),
|
List.copyOf(listing.getPhotos()),
|
||||||
listing.getVirtualTourUrl(),
|
listing.getVirtualTourUrl(),
|
||||||
listing.getOwnerEmail(),
|
publicView ? null : listing.getOwnerEmail(),
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerAvatar,
|
ownerAvatar,
|
||||||
|
ownerAccountType,
|
||||||
|
ownerName,
|
||||||
|
ownerCreatedAt,
|
||||||
|
ownerLastActiveAt,
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getCreatedAt(),
|
listing.getCreatedAt(),
|
||||||
listing.getViewsCount(),
|
listing.getViewsCount(),
|
||||||
@@ -132,31 +151,33 @@ public record ListingDetailResponse(
|
|||||||
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
|
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Double blurLat(PropertyListing listing) {
|
// Zamiast przesuniecia liczonego z (jawnego) identyfikatora ogloszenia - ktore dalo sie odwrocic
|
||||||
|
// i odzyskac dokladny adres - punkt jest przyciagany do srodka kratki siatki. Z odpowiedzi wiadomo
|
||||||
|
// tylko, w ktorej kratce lezy adres. Kratka ma bok rowny promieniowi kola, wiec najdalszy jej rog
|
||||||
|
// (~0,71 promienia od srodka) zawsze miesci sie w rysowanym obszarze, a punkt nie skacze po mapie.
|
||||||
|
private static final double GRID_CELL_M = APPROXIMATE_RADIUS_M;
|
||||||
|
|
||||||
|
static Double blurLat(PropertyListing listing) {
|
||||||
if (listing.getLat() == null) {
|
if (listing.getLat() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE;
|
return snapToCellCenter(listing.getLat(), GRID_CELL_M / METERS_PER_DEGREE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Double blurLng(PropertyListing listing) {
|
static Double blurLng(PropertyListing listing) {
|
||||||
if (listing.getLng() == null || listing.getLat() == null) {
|
if (listing.getLng() == null || listing.getLat() == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
|
// Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
|
||||||
if (parallel == 0) {
|
// dokladnej szerokosci adresu - inaczej sam rozmiar kratki zdradzalby dokladne lat.
|
||||||
|
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(blurLat(listing)));
|
||||||
|
if (parallel <= 0) {
|
||||||
return listing.getLng();
|
return listing.getLng();
|
||||||
}
|
}
|
||||||
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel;
|
return snapToCellCenter(listing.getLng(), GRID_CELL_M / parallel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia,
|
private static double snapToCellCenter(double value, double cellDegrees) {
|
||||||
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
|
return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
|
||||||
private static double offsetAngle(PropertyListing listing) {
|
|
||||||
return Math.toRadians((listing.getId() * 137L) % 360);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double offsetMeters(PropertyListing listing) {
|
|
||||||
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI),
|
* Ulubione ogloszenia uzytkownika - trzymane na koncie, wiec sa te same na kazdym urzadzeniu.
|
||||||
* a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte.
|
* Z tych samych danych liczymy wlascicielowi, ile osob zapisalo jego oferte.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/listings")
|
@RequestMapping("/api/listings")
|
||||||
@@ -49,30 +49,38 @@ public class ListingFavoriteController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Ulubione zalogowanego uzytkownika - te same na kazdym urzadzeniu. */
|
||||||
* Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie
|
@GetMapping("/favorites")
|
||||||
* i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego,
|
public List<ListingResponse> myFavorites(Authentication authentication) {
|
||||||
* z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie.
|
List<Long> listingIds = favoriteRepository.findByUserEmailIgnoreCase(authentication.getName()).stream()
|
||||||
*/
|
.map(ListingFavorite::getListingId)
|
||||||
@PostMapping("/favorites/sync")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Void> sync(@RequestBody SyncRequest request, Authentication authentication) {
|
|
||||||
if (request == null || request.listingIds() == null) {
|
|
||||||
return ResponseEntity.noContent().build();
|
|
||||||
}
|
|
||||||
String userEmail = authentication.getName();
|
|
||||||
List<Long> wanted = request.listingIds().stream()
|
|
||||||
.filter(java.util.Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.filter(listingRepository::existsById)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
if (listingIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
|
||||||
|
return listingRepository.findAllById(listingIds).stream()
|
||||||
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
|
.map(ListingResponse::publicFrom)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream()
|
/**
|
||||||
.filter(favorite -> !wanted.contains(favorite.getListingId()))
|
* Dopisanie ulubionych zapisanych wczesniej w przegladarce. Tylko dodaje - nigdy nie kasuje,
|
||||||
.forEach(favoriteRepository::delete);
|
* zeby wejscie z pustej przegladarki nie wyczyscilo listy zebranej na innym urzadzeniu.
|
||||||
|
*/
|
||||||
wanted.forEach(listingId -> save(listingId, userEmail));
|
@PostMapping("/favorites/import")
|
||||||
return ResponseEntity.noContent().build();
|
@Transactional
|
||||||
|
public List<ListingResponse> importLocal(@RequestBody SyncRequest request, Authentication authentication) {
|
||||||
|
if (request != null && request.listingIds() != null) {
|
||||||
|
String userEmail = authentication.getName();
|
||||||
|
request.listingIds().stream()
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.filter(listingRepository::existsById)
|
||||||
|
.forEach(listingId -> save(listingId, userEmail));
|
||||||
|
}
|
||||||
|
return myFavorites(authentication);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */
|
/** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
||||||
|
|
||||||
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
|
||||||
|
|
||||||
|
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ public record ListingResponse(
|
|||||||
Instant promotedUntil,
|
Instant promotedUntil,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {
|
) {
|
||||||
|
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
|
||||||
public static ListingResponse from(PropertyListing listing) {
|
public static ListingResponse from(PropertyListing listing) {
|
||||||
return from(listing, false);
|
return build(listing, false, listing.getOwnerEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
|
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
|
||||||
* ogladanych spoza panelu wlasciciela i administracji.
|
* adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
|
||||||
*/
|
*/
|
||||||
public static ListingResponse from(PropertyListing listing, boolean masked) {
|
public static ListingResponse publicFrom(PropertyListing listing) {
|
||||||
|
return build(listing, listing.isApproximateLocation(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
|
||||||
String district = listing.getDistrict();
|
String district = listing.getDistrict();
|
||||||
String address = masked
|
String address = masked
|
||||||
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
|
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
|
||||||
@@ -60,7 +65,7 @@ public record ListingResponse(
|
|||||||
listing.getMarket(),
|
listing.getMarket(),
|
||||||
listing.getYearBuilt(),
|
listing.getYearBuilt(),
|
||||||
listing.getCoverPhoto(),
|
listing.getCoverPhoto(),
|
||||||
listing.getOwnerEmail(),
|
ownerEmail,
|
||||||
listing.getStatus(),
|
listing.getStatus(),
|
||||||
listing.getViewsCount(),
|
listing.getViewsCount(),
|
||||||
listing.getPromotedUntil(),
|
listing.getPromotedUntil(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.moderation.TextModerationService;
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
import pl.polskalokalnie.notification.NotificationService;
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
import pl.polskalokalnie.search.SavedSearchNotifier;
|
||||||
import pl.polskalokalnie.user.AccountType;
|
import pl.polskalokalnie.user.AccountType;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
@@ -28,15 +29,17 @@ public class ListingService {
|
|||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserSettingsService userSettingsService;
|
private final UserSettingsService userSettingsService;
|
||||||
|
private final SavedSearchNotifier savedSearchNotifier;
|
||||||
|
|
||||||
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
|
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
|
||||||
NotificationService notificationService, UserRepository userRepository,
|
NotificationService notificationService, UserRepository userRepository,
|
||||||
UserSettingsService userSettingsService) {
|
UserSettingsService userSettingsService, SavedSearchNotifier savedSearchNotifier) {
|
||||||
this.listingRepository = listingRepository;
|
this.listingRepository = listingRepository;
|
||||||
this.textModerationService = textModerationService;
|
this.textModerationService = textModerationService;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userSettingsService = userSettingsService;
|
this.userSettingsService = userSettingsService;
|
||||||
|
this.savedSearchNotifier = savedSearchNotifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
|
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
|
||||||
@@ -52,19 +55,22 @@ public class ListingService {
|
|||||||
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
|
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
|
||||||
*/
|
*/
|
||||||
private ListingDetailResponse detailOf(PropertyListing listing) {
|
private ListingDetailResponse detailOf(PropertyListing listing) {
|
||||||
return detailOf(listing, false);
|
return detailOf(listing, false, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
|
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
|
||||||
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
|
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
|
||||||
|
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
|
||||||
*/
|
*/
|
||||||
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
|
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
|
||||||
AppUser owner = ownerOf(listing);
|
AppUser owner = ownerOf(listing);
|
||||||
if (owner == null) {
|
if (owner == null) {
|
||||||
return ListingDetailResponse.from(listing, null, null, masked);
|
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
|
||||||
}
|
}
|
||||||
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), masked);
|
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
|
||||||
|
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
|
||||||
|
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +101,7 @@ public class ListingService {
|
|||||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||||
.filter(listing -> !directOnly || isDirectOffer(listing))
|
.filter(listing -> !directOnly || isDirectOffer(listing))
|
||||||
.sorted(promotedFirst())
|
.sorted(promotedFirst())
|
||||||
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
|
.map(ListingResponse::publicFrom)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +138,29 @@ public class ListingService {
|
|||||||
listing = listingRepository.save(listing);
|
listing = listingRepository.save(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
|
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
|
||||||
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
|
// lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
|
||||||
|
boolean publicView = !isOwner && !viewerIsAdmin;
|
||||||
|
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
|
||||||
|
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
|
||||||
|
*/
|
||||||
|
public List<ListingResponse> sellerListings(Long listingId) {
|
||||||
|
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
|
||||||
|
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|
||||||
|
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return listingRepository
|
||||||
|
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
|
||||||
|
.stream()
|
||||||
|
.filter(other -> !other.getId().equals(listingId))
|
||||||
|
.limit(6)
|
||||||
|
.map(ListingResponse::publicFrom)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ListingResponse> findMine(String ownerEmail) {
|
public List<ListingResponse> findMine(String ownerEmail) {
|
||||||
@@ -159,7 +186,11 @@ public class ListingService {
|
|||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
|
||||||
}
|
}
|
||||||
listing.setStatus(status);
|
listing.setStatus(status);
|
||||||
return ListingResponse.from(listingRepository.save(listing));
|
PropertyListing saved = listingRepository.save(listing);
|
||||||
|
if (status == ListingStatus.APPROVED) {
|
||||||
|
savedSearchNotifier.notifyMatching(saved);
|
||||||
|
}
|
||||||
|
return ListingResponse.from(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -192,8 +223,8 @@ public class ListingService {
|
|||||||
return detailOf(saved);
|
return detailOf(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
|
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem; istotna zmiana tresci odsyla ogloszenie
|
||||||
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian.
|
// do ponownej weryfikacji, a drobne zmiany (cena, metraz, parametry) sa widoczne od razu.
|
||||||
@Transactional
|
@Transactional
|
||||||
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
|
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
|
||||||
PropertyListing listing = listingRepository.findById(id)
|
PropertyListing listing = listingRepository.findById(id)
|
||||||
@@ -204,13 +235,27 @@ public class ListingService {
|
|||||||
request.title(), request.description(), request.city(), request.address(),
|
request.title(), request.description(), request.city(), request.address(),
|
||||||
request.district(), request.street(), request.contactName());
|
request.district(), request.street(), request.contactName());
|
||||||
|
|
||||||
|
List<Object> before = moderatedContent(listing);
|
||||||
applyRequest(listing, request);
|
applyRequest(listing, request);
|
||||||
// ownerEmail i status pozostaja bez zmian.
|
// Zmiana tresci widocznej dla kupujacych (tytul, opis, zdjecia, kontakt, link spaceru) wraca do
|
||||||
|
// weryfikacji - inaczej zatwierdzone ogloszenie mozna bylo podmienic na dowolne, np. oszustwo.
|
||||||
|
// Dotyczy tez wstrzymanego, bo wlasciciel sam je wznawia. Cena, metraz itp. zmieniaja sie od razu.
|
||||||
|
boolean visible = listing.getStatus() == ListingStatus.APPROVED || listing.getStatus() == ListingStatus.PAUSED;
|
||||||
|
if (visible && !before.equals(moderatedContent(listing))) {
|
||||||
|
listing.setStatus(ListingStatus.PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
PropertyListing saved = listingRepository.save(listing);
|
PropertyListing saved = listingRepository.save(listing);
|
||||||
return detailOf(saved);
|
return detailOf(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pola, ktorych zmiana wymaga ponownej akceptacji administracji.
|
||||||
|
static List<Object> moderatedContent(PropertyListing listing) {
|
||||||
|
return java.util.Arrays.asList(listing.getTitle(), listing.getDescription(), List.copyOf(listing.getPhotos()),
|
||||||
|
listing.getContactName(), listing.getContactPhone(), listing.getContactEmail(),
|
||||||
|
listing.getVirtualTourUrl());
|
||||||
|
}
|
||||||
|
|
||||||
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
|
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
|
||||||
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
|
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
|
||||||
listing.setTitle(request.title().trim());
|
listing.setTitle(request.title().trim());
|
||||||
@@ -271,6 +316,11 @@ public class ListingService {
|
|||||||
listing.setStatus(status);
|
listing.setStatus(status);
|
||||||
PropertyListing saved = listingRepository.save(listing);
|
PropertyListing saved = listingRepository.save(listing);
|
||||||
notifyOwnerStatusChange(saved, status);
|
notifyOwnerStatusChange(saved, status);
|
||||||
|
if (status == ListingStatus.APPROVED) {
|
||||||
|
// Ogloszenie wlasnie stalo sie widoczne - szukajacy z pasujacym zapisanym
|
||||||
|
// wyszukiwaniem dostaja powiadomienie na liscie w serwisie.
|
||||||
|
savedSearchNotifier.notifyMatching(saved);
|
||||||
|
}
|
||||||
return ListingResponse.from(saved);
|
return ListingResponse.from(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +354,17 @@ public class ListingService {
|
|||||||
listingRepository.deleteById(id);
|
listingRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
|
||||||
|
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteAllOfOwner(String ownerEmail) {
|
||||||
|
if (ownerEmail != null && !ownerEmail.isBlank()) {
|
||||||
|
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String trimToNull(String value) {
|
private static String trimToNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -325,11 +386,20 @@ public class ListingService {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> limitPhotos(List<String> photos) {
|
// Zdjecia przyjmujemy wylacznie jako data URL obrazu rastrowego. Inny typ (np. text/html,
|
||||||
|
// image/svg+xml) serwowany potem z domeny serwisu pozwalalby uruchomic skrypt u oglądającego.
|
||||||
|
private static final java.util.regex.Pattern PHOTO_DATA_URL =
|
||||||
|
java.util.regex.Pattern.compile("^data:image/(jpeg|png|webp);base64,[A-Za-z0-9+/=\\s]+$");
|
||||||
|
|
||||||
|
static List<String> limitPhotos(List<String> photos) {
|
||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
if (photos != null) {
|
if (photos != null) {
|
||||||
for (String photo : photos) {
|
for (String photo : photos) {
|
||||||
if (photo != null && !photo.isBlank()) {
|
if (photo != null && !photo.isBlank()) {
|
||||||
|
if (!PHOTO_DATA_URL.matcher(photo).matches()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Nieprawidłowy format zdjęcia - dozwolone są obrazy JPEG, PNG i WebP");
|
||||||
|
}
|
||||||
result.add(photo);
|
result.add(photo);
|
||||||
if (result.size() >= MAX_PHOTOS) {
|
if (result.size() >= MAX_PHOTOS) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package pl.polskalokalnie.listing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
|
||||||
|
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
|
||||||
|
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ListingViewGuard {
|
||||||
|
|
||||||
|
private static final Duration WINDOW = Duration.ofHours(1);
|
||||||
|
private static final int MAX_ENTRIES = 100_000;
|
||||||
|
|
||||||
|
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
|
||||||
|
public boolean shouldCount(Long listingId, String viewer) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (lastCounted.size() > MAX_ENTRIES) {
|
||||||
|
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
|
||||||
|
if (lastCounted.size() > MAX_ENTRIES) {
|
||||||
|
lastCounted.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String key = listingId + "|" + (viewer == null ? "" : viewer);
|
||||||
|
Instant previous = lastCounted.get(key);
|
||||||
|
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
lastCounted.put(key, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package pl.polskalokalnie.market;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna,
|
||||||
|
* czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu.
|
||||||
|
*
|
||||||
|
* DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki
|
||||||
|
* lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym
|
||||||
|
* pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen
|
||||||
|
* mieszkan, ktory juz pobieramy w module historii cen.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class EurostatRateClient {
|
||||||
|
|
||||||
|
static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m"
|
||||||
|
+ "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6";
|
||||||
|
|
||||||
|
/** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */
|
||||||
|
public record Rate(BigDecimal percent, String period) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public Rate latestThreeMonthRate() throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("Eurostat odpowiedzial " + response.statusCode());
|
||||||
|
}
|
||||||
|
return parseLatest(mapper.readTree(response.body()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar
|
||||||
|
* czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem
|
||||||
|
* indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time".
|
||||||
|
*/
|
||||||
|
static Rate parseLatest(JsonNode root) throws IOException {
|
||||||
|
JsonNode values = root.path("value");
|
||||||
|
JsonNode periods = root.path("dimension").path("time").path("category").path("index");
|
||||||
|
if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) {
|
||||||
|
throw new IOException("Eurostat: odpowiedz bez danych");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste.
|
||||||
|
Map.Entry<String, JsonNode> latest = null;
|
||||||
|
for (var it = periods.fields(); it.hasNext(); ) {
|
||||||
|
Map.Entry<String, JsonNode> entry = it.next();
|
||||||
|
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|
||||||
|
|| Comparator.<String>naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) {
|
||||||
|
latest = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (latest == null) {
|
||||||
|
throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca");
|
||||||
|
}
|
||||||
|
BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue();
|
||||||
|
if (percent.signum() <= 0) {
|
||||||
|
throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent);
|
||||||
|
}
|
||||||
|
return new Rate(percent, latest.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package pl.polskalokalnie.market;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku
|
||||||
|
* pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem
|
||||||
|
* pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "market_indicators")
|
||||||
|
public class MarketIndicator {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "indicator_key", nullable = false, unique = true, length = 40)
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 12, scale = 4)
|
||||||
|
private BigDecimal value;
|
||||||
|
|
||||||
|
/** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */
|
||||||
|
@Column(name = "as_of", nullable = false, length = 20)
|
||||||
|
private String asOf;
|
||||||
|
|
||||||
|
/** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
@Column(name = "fetched_at", nullable = false)
|
||||||
|
private Instant fetchedAt;
|
||||||
|
|
||||||
|
protected MarketIndicator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
|
||||||
|
this.key = key;
|
||||||
|
this.value = value;
|
||||||
|
this.asOf = asOf;
|
||||||
|
this.source = source;
|
||||||
|
this.fetchedAt = fetchedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getKey() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValue(BigDecimal value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAsOf() {
|
||||||
|
return asOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAsOf(String asOf) {
|
||||||
|
this.asOf = asOf;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSource(String source) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getFetchedAt() {
|
||||||
|
return fetchedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFetchedAt(Instant fetchedAt) {
|
||||||
|
this.fetchedAt = fetchedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package pl.polskalokalnie.market;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator.
|
||||||
|
* Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/market-indicators")
|
||||||
|
public class MarketIndicatorController {
|
||||||
|
|
||||||
|
public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final MarketIndicatorService service;
|
||||||
|
|
||||||
|
public MarketIndicatorController(MarketIndicatorService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<IndicatorResponse> list() {
|
||||||
|
return service.all().stream()
|
||||||
|
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
|
||||||
|
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pl.polskalokalnie.market;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
|
||||||
|
|
||||||
|
Optional<MarketIndicator> findByKey(String key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package pl.polskalokalnie.market;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import pl.polskalokalnie.pricehistory.GusBdlClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny
|
||||||
|
* transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej
|
||||||
|
* jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class MarketIndicatorService {
|
||||||
|
|
||||||
|
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */
|
||||||
|
public static final String AVERAGE_WAGE = "average-wage-gross";
|
||||||
|
/** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */
|
||||||
|
public static final String MONEY_MARKET_3M = "money-market-3m";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class);
|
||||||
|
|
||||||
|
private final MarketIndicatorRepository repository;
|
||||||
|
private final GusBdlClient gusClient;
|
||||||
|
private final EurostatRateClient eurostatClient;
|
||||||
|
|
||||||
|
public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient,
|
||||||
|
EurostatRateClient eurostatClient) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.gusClient = gusClient;
|
||||||
|
this.eurostatClient = eurostatClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<MarketIndicator> all() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void refreshOnStartupIfStale() {
|
||||||
|
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||||
|
if (isStale(AVERAGE_WAGE, weekAgo)) {
|
||||||
|
refreshAverageWage();
|
||||||
|
}
|
||||||
|
if (isStale(MONEY_MARKET_3M, weekAgo)) {
|
||||||
|
refreshMoneyMarketRate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw")
|
||||||
|
public void refreshWeekly() {
|
||||||
|
refreshAverageWage();
|
||||||
|
refreshMoneyMarketRate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean refreshAverageWage() {
|
||||||
|
try {
|
||||||
|
GusBdlClient.AnnualValue wage = gusClient.latestAverageWage();
|
||||||
|
save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych");
|
||||||
|
log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year());
|
||||||
|
return true;
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc",
|
||||||
|
ex.getMessage());
|
||||||
|
return false;
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean refreshMoneyMarketRate() {
|
||||||
|
try {
|
||||||
|
EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate();
|
||||||
|
save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)");
|
||||||
|
log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period());
|
||||||
|
return true;
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc",
|
||||||
|
ex.getMessage());
|
||||||
|
return false;
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStale(String key, Instant threshold) {
|
||||||
|
return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
protected void save(String key, BigDecimal value, String asOf, String source) {
|
||||||
|
Optional<MarketIndicator> existing = repository.findByKey(key);
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
MarketIndicator indicator = existing.get();
|
||||||
|
indicator.setValue(value);
|
||||||
|
indicator.setAsOf(asOf);
|
||||||
|
indicator.setSource(source);
|
||||||
|
indicator.setFetchedAt(now);
|
||||||
|
repository.save(indicator);
|
||||||
|
} else {
|
||||||
|
repository.save(new MarketIndicator(key, value, asOf, source, now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,291 @@
|
|||||||
|
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());
|
||||||
|
// Zablokowany uzytkownik nie moze pisac - propozycja spotkania to tez wiadomosc w rozmowie.
|
||||||
|
conversationService.requireCanWrite(conversation, me);
|
||||||
|
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) {
|
||||||
|
if (!wantsNotifications(recipient)) {
|
||||||
|
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) {
|
||||||
|
// Terminy oglądania ustala się w rozmowie, więc podlegają temu samemu przełącznikowi
|
||||||
|
// co wiadomości. Sam termin zostaje w kalendarzu na /konto/spotkania.
|
||||||
|
if (!wantsNotifications(recipient)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notificationService.create(recipient.getEmail(), "messages", "calendar", title, body,
|
||||||
|
conversationId == null ? "meetings" : "messages:conversation:" + conversationId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean wantsNotifications(AppUser recipient) {
|
||||||
|
return userSettingsRepository.findByUserId(recipient.getId())
|
||||||
|
.map(UserSettings::isNotifyMessages)
|
||||||
|
.orElse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
// Limity kolumn w bazie - dluzszy tekst konczyl sie bledem 500 zamiast czytelnym komunikatem.
|
||||||
|
if (request.title().trim().length() > 160
|
||||||
|
|| (request.note() != null && request.note().trim().length() > 200)
|
||||||
|
|| (request.location() != null && request.location().trim().length() > 200)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Za długi tekst - nazwa do 160 znaków, notatka i miejsce do 200 znaków");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
// Bez imienia - nick, jak w wiadomosciach. Adres e-mail (login) nie trafia do drugiej strony.
|
||||||
|
return user.getNick() != null ? user.getNick() : "Użytkownik";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimOrNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozmowa o konkretnym ogloszeniu: jeden watek na pare (ogloszenie, kupujacy). Sprzedajacy to
|
||||||
|
* wlasciciel ogloszenia w chwili zalozenia rozmowy. Wiadomosci leza w tabeli messages
|
||||||
|
* z ustawionym conversationId.
|
||||||
|
*
|
||||||
|
* Tytul ogloszenia jest zapisany w rozmowie, bo ogloszenie moze zostac usuniete - historia
|
||||||
|
* rozmowy ma wtedy nadal czytelny temat.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "conversations", uniqueConstraints = @UniqueConstraint(columnNames = {"listingId", "buyerId"}))
|
||||||
|
public class Conversation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long listingId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 300)
|
||||||
|
private String listingTitle;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long buyerId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long sellerId;
|
||||||
|
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant lastMessageAt;
|
||||||
|
|
||||||
|
// Ukrycie rozmowy dziala osobno dla kazdej strony; nowa wiadomosc przywraca ja obu.
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean buyerDeleted = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean sellerDeleted = false;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = now;
|
||||||
|
}
|
||||||
|
if (lastMessageAt == null) {
|
||||||
|
lastMessageAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isParticipant(Long userId) {
|
||||||
|
return buyerId.equals(userId) || sellerId.equals(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long otherParticipant(Long userId) {
|
||||||
|
return buyerId.equals(userId) ? sellerId : buyerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDeletedFor(Long userId) {
|
||||||
|
return buyerId.equals(userId) ? buyerDeleted : sellerDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markDeletedFor(Long userId) {
|
||||||
|
if (buyerId.equals(userId)) {
|
||||||
|
buyerDeleted = true;
|
||||||
|
} else {
|
||||||
|
sellerDeleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void restoreForBoth() {
|
||||||
|
buyerDeleted = false;
|
||||||
|
sellerDeleted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getListingId() {
|
||||||
|
return listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListingId(Long listingId) {
|
||||||
|
this.listingId = listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getListingTitle() {
|
||||||
|
return listingTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListingTitle(String listingTitle) {
|
||||||
|
this.listingTitle = listingTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getBuyerId() {
|
||||||
|
return buyerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuyerId(Long buyerId) {
|
||||||
|
this.buyerId = buyerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getSellerId() {
|
||||||
|
return sellerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSellerId(Long sellerId) {
|
||||||
|
this.sellerId = sellerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getLastMessageAt() {
|
||||||
|
return lastMessageAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastMessageAt(Instant lastMessageAt) {
|
||||||
|
this.lastMessageAt = lastMessageAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.StartConversationRequest;
|
||||||
|
import pl.polskalokalnie.user.AppUser;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
|
/** Rozmowy miedzy uzytkownikami o ogloszeniach. Wszystkie trasy wymagaja zalogowania. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/conversations")
|
||||||
|
public class ConversationController {
|
||||||
|
|
||||||
|
private final ConversationService conversationService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public ConversationController(ConversationService conversationService, UserRepository userRepository) {
|
||||||
|
this.conversationService = conversationService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ConversationDetail start(Authentication authentication, @Valid @RequestBody StartConversationRequest request) {
|
||||||
|
return conversationService.start(currentUser(authentication), request.listingId(), request.content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<ConversationSummary> list(Authentication authentication) {
|
||||||
|
return conversationService.list(currentUser(authentication));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ConversationDetail get(Authentication authentication, @PathVariable Long id) {
|
||||||
|
return conversationService.get(currentUser(authentication), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/messages")
|
||||||
|
public ConversationDetail reply(Authentication authentication, @PathVariable Long id,
|
||||||
|
@Valid @RequestBody SendMessageRequest request) {
|
||||||
|
return conversationService.reply(currentUser(authentication), id, request.content());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/block")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void block(Authentication authentication, @PathVariable Long id) {
|
||||||
|
conversationService.block(currentUser(authentication), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/block")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void unblock(Authentication authentication, @PathVariable Long id) {
|
||||||
|
conversationService.unblock(currentUser(authentication), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(Authentication authentication, @PathVariable Long id) {
|
||||||
|
conversationService.deleteForUser(currentUser(authentication), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser currentUser(Authentication authentication) {
|
||||||
|
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Obiekty wymiany danych dla rozmow o ogloszeniach (/api/conversations). */
|
||||||
|
public final class ConversationDtos {
|
||||||
|
|
||||||
|
private ConversationDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartConversationRequest(
|
||||||
|
@NotNull Long listingId,
|
||||||
|
@NotBlank @Size(max = 2000) String content
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skrot ogloszenia w naglowku rozmowy; pola ogloszenia sa null, gdy zostalo usuniete. */
|
||||||
|
public record ConversationListing(
|
||||||
|
Long listingId,
|
||||||
|
String title,
|
||||||
|
boolean available,
|
||||||
|
BigDecimal price,
|
||||||
|
String city,
|
||||||
|
String district,
|
||||||
|
Double area,
|
||||||
|
Integer rooms,
|
||||||
|
String floor,
|
||||||
|
String buildingFloors,
|
||||||
|
String thumbnail
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ConversationSummary(
|
||||||
|
Long id,
|
||||||
|
ConversationListing listing,
|
||||||
|
Long otherUserId,
|
||||||
|
String otherUserName,
|
||||||
|
boolean otherUserExists,
|
||||||
|
boolean selling,
|
||||||
|
String lastMessage,
|
||||||
|
boolean lastMessageMine,
|
||||||
|
long unreadCount,
|
||||||
|
Instant lastMessageAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** read ma znaczenie tylko dla wlasnych wiadomosci - czy druga strona je przeczytala. */
|
||||||
|
public record ConversationMessage(
|
||||||
|
Long id,
|
||||||
|
String content,
|
||||||
|
boolean mine,
|
||||||
|
boolean read,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ConversationDetail(
|
||||||
|
Long id,
|
||||||
|
ConversationListing listing,
|
||||||
|
Long otherUserId,
|
||||||
|
String otherUserName,
|
||||||
|
boolean otherUserExists,
|
||||||
|
boolean selling,
|
||||||
|
boolean blockedByMe,
|
||||||
|
boolean blocked,
|
||||||
|
List<ConversationMessage> messages
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
import pl.polskalokalnie.auth.AccountEmailService;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
import pl.polskalokalnie.user.UserSettings;
|
||||||
|
import pl.polskalokalnie.user.UserSettingsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Powiadamia odbiorce o nowej wiadomosci w rozmowie o ogloszeniu - dopiero po zatwierdzeniu
|
||||||
|
* transakcji, zeby nie powiadamiac o wiadomosci, ktora ostatecznie sie nie zapisala. Dziala w tle:
|
||||||
|
* wolny serwer SMTP nie opoznia wysylki wiadomosci, a blad wysylki nie cofa jej zapisu.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ConversationMessageNotifier {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ConversationMessageNotifier.class);
|
||||||
|
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final AccountEmailService accountEmailService;
|
||||||
|
private final UserSettingsRepository userSettingsRepository;
|
||||||
|
|
||||||
|
public ConversationMessageNotifier(NotificationService notificationService,
|
||||||
|
AccountEmailService accountEmailService,
|
||||||
|
UserSettingsRepository userSettingsRepository) {
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.accountEmailService = accountEmailService;
|
||||||
|
this.userSettingsRepository = userSettingsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MessageSentEvent(
|
||||||
|
Long conversationId,
|
||||||
|
Long recipientId,
|
||||||
|
String recipientEmail,
|
||||||
|
String recipientName,
|
||||||
|
String senderName,
|
||||||
|
String listingTitle,
|
||||||
|
String content
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
public void onMessageSent(MessageSentEvent event) {
|
||||||
|
try {
|
||||||
|
// Przelacznik "Wiadomości od użytkowników" w ustawieniach konta wycisza oba kanaly:
|
||||||
|
// powiadomienie w serwisie i e-mail. Sama wiadomosc i tak czeka w /wiadomosci.
|
||||||
|
boolean wantsNotification = userSettingsRepository.findByUserId(event.recipientId())
|
||||||
|
.map(UserSettings::isNotifyMessages)
|
||||||
|
.orElse(true);
|
||||||
|
if (!wantsNotification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String preview = event.content().length() > 90 ? event.content().substring(0, 90) + "…" : event.content();
|
||||||
|
notificationService.create(event.recipientEmail(), "messages", "message",
|
||||||
|
"Nowa wiadomość: " + event.listingTitle(),
|
||||||
|
event.senderName() + ": " + preview,
|
||||||
|
"messages:conversation:" + event.conversationId(), null);
|
||||||
|
|
||||||
|
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
|
||||||
|
event.listingTitle(), event.content(), event.conversationId());
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ConversationRepository extends JpaRepository<Conversation, Long> {
|
||||||
|
|
||||||
|
Optional<Conversation> findByListingIdAndBuyerId(Long listingId, Long buyerId);
|
||||||
|
|
||||||
|
// Rozmowy widoczne dla uzytkownika - bez tych, ktore ukryl u siebie.
|
||||||
|
@Query("select c from Conversation c where (c.buyerId = :userId and c.buyerDeleted = false) "
|
||||||
|
+ "or (c.sellerId = :userId and c.sellerDeleted = false) order by c.lastMessageAt desc")
|
||||||
|
List<Conversation> findVisibleFor(@Param("userId") Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationListing;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationMessage;
|
||||||
|
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
|
||||||
|
import pl.polskalokalnie.message.ConversationMessageNotifier.MessageSentEvent;
|
||||||
|
import pl.polskalokalnie.moderation.TextModerationService;
|
||||||
|
import pl.polskalokalnie.user.AppUser;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozmowy miedzy uzytkownikami o ogloszeniach. Watek zaklada kupujacy pierwsza wiadomoscia
|
||||||
|
* z ogloszenia; sprzedajacy odpowiada w istniejacym watku. Dostep maja wylacznie obie strony.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ConversationService {
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final MessageRepository messageRepository;
|
||||||
|
private final UserBlockRepository userBlockRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final TextModerationService textModerationService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
public ConversationService(ConversationRepository conversationRepository,
|
||||||
|
MessageRepository messageRepository,
|
||||||
|
UserBlockRepository userBlockRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
ListingRepository listingRepository,
|
||||||
|
TextModerationService textModerationService,
|
||||||
|
ApplicationEventPublisher eventPublisher) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
this.userBlockRepository = userBlockRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
this.textModerationService = textModerationService;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ConversationDetail start(AppUser me, Long listingId, String content) {
|
||||||
|
PropertyListing listing = listingRepository.findById(listingId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||||
|
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
|
||||||
|
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
|
||||||
|
if (listing.getStatus() != ListingStatus.APPROVED
|
||||||
|
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
|
||||||
|
}
|
||||||
|
AppUser seller = listing.getOwnerEmail() == null ? null
|
||||||
|
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
|
||||||
|
if (seller == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Z autorem tego ogłoszenia nie można się skontaktować");
|
||||||
|
}
|
||||||
|
if (seller.getId().equals(me.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie możesz napisać wiadomości do samego siebie");
|
||||||
|
}
|
||||||
|
requireNotBlocked(me.getId(), seller.getId());
|
||||||
|
|
||||||
|
Conversation conversation = conversationRepository.findByListingIdAndBuyerId(listingId, me.getId())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Conversation created = new Conversation();
|
||||||
|
created.setListingId(listingId);
|
||||||
|
created.setListingTitle(listing.getTitle());
|
||||||
|
created.setBuyerId(me.getId());
|
||||||
|
created.setSellerId(seller.getId());
|
||||||
|
return conversationRepository.save(created);
|
||||||
|
});
|
||||||
|
append(conversation, me, seller, content);
|
||||||
|
return detail(conversation, me.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ConversationSummary> list(AppUser me) {
|
||||||
|
List<Conversation> conversations = conversationRepository.findVisibleFor(me.getId());
|
||||||
|
if (conversations.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Set<Long> ids = conversations.stream().map(Conversation::getId).collect(Collectors.toSet());
|
||||||
|
Map<Long, Message> latest = messageRepository.findLatestPerConversation(ids).stream()
|
||||||
|
.collect(Collectors.toMap(Message::getConversationId, Function.identity()));
|
||||||
|
Map<Long, Long> unread = new HashMap<>();
|
||||||
|
for (Object[] row : messageRepository.countUnreadPerConversation(ids, me.getId())) {
|
||||||
|
unread.put((Long) row[0], (Long) row[1]);
|
||||||
|
}
|
||||||
|
Map<Long, AppUser> users = userRepository.findAllById(conversations.stream()
|
||||||
|
.map(conversation -> conversation.otherParticipant(me.getId())).collect(Collectors.toSet()))
|
||||||
|
.stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
|
||||||
|
Map<Long, PropertyListing> listings = listingRepository.findAllById(conversations.stream()
|
||||||
|
.map(Conversation::getListingId).collect(Collectors.toSet()))
|
||||||
|
.stream().collect(Collectors.toMap(PropertyListing::getId, Function.identity()));
|
||||||
|
|
||||||
|
return conversations.stream().map(conversation -> {
|
||||||
|
AppUser other = users.get(conversation.otherParticipant(me.getId()));
|
||||||
|
Message last = latest.get(conversation.getId());
|
||||||
|
return new ConversationSummary(
|
||||||
|
conversation.getId(),
|
||||||
|
listingOf(conversation, listings.get(conversation.getListingId())),
|
||||||
|
conversation.otherParticipant(me.getId()),
|
||||||
|
displayName(other),
|
||||||
|
other != null,
|
||||||
|
conversation.getSellerId().equals(me.getId()),
|
||||||
|
last == null ? "" : last.getContent(),
|
||||||
|
last != null && last.getSenderId().equals(me.getId()),
|
||||||
|
unread.getOrDefault(conversation.getId(), 0L),
|
||||||
|
conversation.getLastMessageAt());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Otwarcie rozmowy oznacza wiadomosci od drugiej strony jako przeczytane. */
|
||||||
|
@Transactional
|
||||||
|
public ConversationDetail get(AppUser me, Long conversationId) {
|
||||||
|
Conversation conversation = requireParticipant(me, conversationId);
|
||||||
|
if (conversation.isDeletedFor(me.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy");
|
||||||
|
}
|
||||||
|
messageRepository.markConversationMessagesRead(conversation.getId(), me.getId());
|
||||||
|
return detail(conversation, me.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ConversationDetail reply(AppUser me, Long conversationId, String content) {
|
||||||
|
Conversation conversation = requireParticipant(me, conversationId);
|
||||||
|
AppUser other = userRepository.findById(conversation.otherParticipant(me.getId()))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto rozmówcy zostało usunięte"));
|
||||||
|
requireNotBlocked(me.getId(), other.getId());
|
||||||
|
append(conversation, me, other, content);
|
||||||
|
return detail(conversation, me.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void block(AppUser me, Long conversationId) {
|
||||||
|
Conversation conversation = requireParticipant(me, conversationId);
|
||||||
|
Long otherId = conversation.otherParticipant(me.getId());
|
||||||
|
if (!userBlockRepository.existsByBlockerIdAndBlockedId(me.getId(), otherId)) {
|
||||||
|
userBlockRepository.save(new UserBlock(me.getId(), otherId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void unblock(AppUser me, Long conversationId) {
|
||||||
|
Conversation conversation = requireParticipant(me, conversationId);
|
||||||
|
userBlockRepository.deleteByBlockerIdAndBlockedId(me.getId(), conversation.otherParticipant(me.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ukrywa rozmowe tylko u wywolujacego; nowa wiadomosc przywroci ja obu stronom. */
|
||||||
|
@Transactional
|
||||||
|
public void deleteForUser(AppUser me, Long conversationId) {
|
||||||
|
Conversation conversation = requireParticipant(me, conversationId);
|
||||||
|
conversation.markDeletedFor(me.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Licznik nieprzeczytanych wiadomosci we wszystkich rozmowach o ogloszeniach. */
|
||||||
|
public long unreadCount(AppUser me) {
|
||||||
|
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
// Notatka niesie tekst od uzytkownika (miejsce spotkania) - podlega tej samej moderacji co wiadomosc.
|
||||||
|
textModerationService.validateOrThrow(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();
|
||||||
|
|
||||||
|
Message message = new Message();
|
||||||
|
message.setConversationId(conversation.getId());
|
||||||
|
message.setSenderId(sender.getId());
|
||||||
|
message.setRecipientId(recipient.getId());
|
||||||
|
message.setContent(trimmed);
|
||||||
|
messageRepository.save(message);
|
||||||
|
|
||||||
|
conversation.setLastMessageAt(Instant.now());
|
||||||
|
conversation.restoreForBoth();
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(new MessageSentEvent(
|
||||||
|
conversation.getId(),
|
||||||
|
recipient.getId(),
|
||||||
|
recipient.getEmail(),
|
||||||
|
recipient.getFullName(),
|
||||||
|
displayName(sender),
|
||||||
|
conversation.getListingTitle(),
|
||||||
|
trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConversationDetail detail(Conversation conversation, Long meId) {
|
||||||
|
Long otherId = conversation.otherParticipant(meId);
|
||||||
|
AppUser other = userRepository.findById(otherId).orElse(null);
|
||||||
|
PropertyListing listing = listingRepository.findById(conversation.getListingId()).orElse(null);
|
||||||
|
boolean blockedByMe = userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId);
|
||||||
|
boolean blocked = blockedByMe || userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId);
|
||||||
|
List<ConversationMessage> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId())
|
||||||
|
.stream()
|
||||||
|
.map(message -> new ConversationMessage(
|
||||||
|
message.getId(),
|
||||||
|
message.getContent(),
|
||||||
|
message.getSenderId().equals(meId),
|
||||||
|
message.isRead(),
|
||||||
|
message.getCreatedAt()))
|
||||||
|
.toList();
|
||||||
|
return new ConversationDetail(
|
||||||
|
conversation.getId(),
|
||||||
|
listingOf(conversation, listing),
|
||||||
|
otherId,
|
||||||
|
displayName(other),
|
||||||
|
other != null,
|
||||||
|
conversation.getSellerId().equals(meId),
|
||||||
|
blockedByMe,
|
||||||
|
blocked,
|
||||||
|
messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConversationListing listingOf(Conversation conversation, PropertyListing listing) {
|
||||||
|
if (listing == null) {
|
||||||
|
return new ConversationListing(conversation.getListingId(), conversation.getListingTitle(), false,
|
||||||
|
null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
return new ConversationListing(
|
||||||
|
listing.getId(),
|
||||||
|
listing.getTitle(),
|
||||||
|
listing.getStatus() == ListingStatus.APPROVED,
|
||||||
|
listing.getPrice(),
|
||||||
|
listing.getCity(),
|
||||||
|
listing.getDistrict(),
|
||||||
|
listing.getArea(),
|
||||||
|
listing.getRooms(),
|
||||||
|
listing.getFloor(),
|
||||||
|
listing.getBuildingFloors(),
|
||||||
|
listing.getCoverPhoto());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Conversation requireParticipant(AppUser me, Long conversationId) {
|
||||||
|
Conversation conversation = conversationRepository.findById(conversationId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
|
||||||
|
if (!conversation.isParticipant(me.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
|
||||||
|
}
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blokada miedzy uczestnikami rozmowy obowiazuje tez poza wiadomosciami (np. propozycje spotkan). */
|
||||||
|
public void requireCanWrite(Conversation conversation, AppUser me) {
|
||||||
|
requireNotBlocked(me.getId(), conversation.otherParticipant(me.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireNotBlocked(Long meId, Long otherId) {
|
||||||
|
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
|
||||||
|
}
|
||||||
|
if (userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz pisać do tego użytkownika.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String displayName(AppUser user) {
|
||||||
|
if (user == null) {
|
||||||
|
return "Konto usunięte";
|
||||||
|
}
|
||||||
|
if (user.getFullName() != null && !user.getFullName().isBlank()) {
|
||||||
|
return user.getFullName().trim();
|
||||||
|
}
|
||||||
|
return user.getNick() != null ? user.getNick() : "Użytkownik";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ public class Message {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private Long recipientId;
|
private Long recipientId;
|
||||||
|
|
||||||
|
// Rozmowa o ogloszeniu miedzy uzytkownikami; null = rozmowa z administracja serwisu.
|
||||||
|
private Long conversationId;
|
||||||
|
|
||||||
@Column(nullable = false, length = 2000)
|
@Column(nullable = false, length = 2000)
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -59,6 +62,14 @@ public class Message {
|
|||||||
this.recipientId = recipientId;
|
this.recipientId = recipientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getConversationId() {
|
||||||
|
return conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConversationId(Long conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getContent() {
|
public String getContent() {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import pl.polskalokalnie.user.UserRepository;
|
|||||||
public class MessageController {
|
public class MessageController {
|
||||||
|
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
|
private final ConversationService conversationService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public MessageController(MessageService messageService, UserRepository userRepository) {
|
public MessageController(MessageService messageService, ConversationService conversationService,
|
||||||
|
UserRepository userRepository) {
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
|
this.conversationService = conversationService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,11 +42,13 @@ public class MessageController {
|
|||||||
return messageService.send(me.getId(), admin.getId(), request.content());
|
return messageService.send(me.getId(), admin.getId(), request.content());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wszystkie nieprzeczytane: od administracji oraz z rozmow o ogloszeniach.
|
||||||
@GetMapping("/unread-count")
|
@GetMapping("/unread-count")
|
||||||
public UnreadCountResponse unreadCount(Authentication authentication) {
|
public UnreadCountResponse unreadCount(Authentication authentication) {
|
||||||
AppUser me = currentUser(authentication);
|
AppUser me = currentUser(authentication);
|
||||||
AppUser admin = messageService.resolveAdmin();
|
AppUser admin = messageService.resolveAdmin();
|
||||||
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId()));
|
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId())
|
||||||
|
+ conversationService.unreadCount(me));
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppUser currentUser(Authentication authentication) {
|
private AppUser currentUser(Authentication authentication) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package pl.polskalokalnie.message;
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
@@ -9,14 +10,38 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
public interface MessageRepository extends JpaRepository<Message, Long> {
|
public interface MessageRepository extends JpaRepository<Message, Long> {
|
||||||
|
|
||||||
@Query("select m from Message m where (m.senderId = :first and m.recipientId = :second) "
|
// --- Rozmowa z administracja (wiadomosci bez conversationId) ---
|
||||||
+ "or (m.senderId = :second and m.recipientId = :first) order by m.createdAt asc")
|
|
||||||
|
@Query("select m from Message m where m.conversationId is null and ((m.senderId = :first and m.recipientId = :second) "
|
||||||
|
+ "or (m.senderId = :second and m.recipientId = :first)) order by m.createdAt asc")
|
||||||
List<Message> findConversation(@Param("first") Long first, @Param("second") Long second);
|
List<Message> findConversation(@Param("first") Long first, @Param("second") Long second);
|
||||||
|
|
||||||
long countByRecipientIdAndSenderIdAndReadFalse(Long recipientId, Long senderId);
|
long countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(Long recipientId, Long senderId);
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Transactional
|
@Transactional
|
||||||
@Query("update Message m set m.read = true where m.recipientId = :recipientId and m.senderId = :senderId and m.read = false")
|
@Query("update Message m set m.read = true where m.conversationId is null and m.recipientId = :recipientId "
|
||||||
|
+ "and m.senderId = :senderId and m.read = false")
|
||||||
void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId);
|
void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId);
|
||||||
|
|
||||||
|
// --- Rozmowy o ogloszeniach miedzy uzytkownikami ---
|
||||||
|
|
||||||
|
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
|
||||||
|
|
||||||
|
// Ostatnia wiadomosc kazdej rozmowy jednym zapytaniem - bez N+1 na liscie rozmow.
|
||||||
|
@Query("select m from Message m where m.id in "
|
||||||
|
+ "(select max(m2.id) from Message m2 where m2.conversationId in :ids group by m2.conversationId)")
|
||||||
|
List<Message> findLatestPerConversation(@Param("ids") Collection<Long> ids);
|
||||||
|
|
||||||
|
@Query("select m.conversationId, count(m) from Message m where m.conversationId in :ids "
|
||||||
|
+ "and m.recipientId = :userId and m.read = false group by m.conversationId")
|
||||||
|
List<Object[]> countUnreadPerConversation(@Param("ids") Collection<Long> ids, @Param("userId") Long userId);
|
||||||
|
|
||||||
|
long countByRecipientIdAndReadFalseAndConversationIdIsNotNull(Long recipientId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query("update Message m set m.read = true where m.conversationId = :conversationId "
|
||||||
|
+ "and m.recipientId = :recipientId and m.read = false")
|
||||||
|
void markConversationMessagesRead(@Param("conversationId") Long conversationId, @Param("recipientId") Long recipientId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ public class MessageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long countUnreadFrom(Long recipientId, Long senderId) {
|
public long countUnreadFrom(Long recipientId, Long senderId) {
|
||||||
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
|
return messageRepository.countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(recipientId, senderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.PrePersist;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Blokada miedzy uzytkownikami - wstrzymuje wiadomosci w obu kierunkach. */
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_blocks", uniqueConstraints = @UniqueConstraint(columnNames = {"blockerId", "blockedId"}))
|
||||||
|
public class UserBlock {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long blockerId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long blockedId;
|
||||||
|
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
protected UserBlock() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserBlock(Long blockerId, Long blockedId) {
|
||||||
|
this.blockerId = blockerId;
|
||||||
|
this.blockedId = blockedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getBlockerId() {
|
||||||
|
return blockerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getBlockedId() {
|
||||||
|
return blockedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pl.polskalokalnie.message;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
public interface UserBlockRepository extends JpaRepository<UserBlock, Long> {
|
||||||
|
|
||||||
|
boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import pl.polskalokalnie.auth.JwtService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/notifications")
|
@RequestMapping("/api/notifications")
|
||||||
public class NotificationController {
|
public class NotificationController {
|
||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
public NotificationController(NotificationService notificationService) {
|
public NotificationController(NotificationService notificationService, JwtService jwtService) {
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
|
this.jwtService = jwtService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -86,6 +89,15 @@ public class NotificationController {
|
|||||||
return created == null ? null : NotificationResponse.from(created);
|
return created == null ? null : NotificationResponse.from(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Krotki token do otwarcia strumienia (EventSource przekazuje go w adresie, nie w naglowku). */
|
||||||
|
@PostMapping("/stream-token")
|
||||||
|
public StreamTokenResponse streamToken(Authentication authentication) {
|
||||||
|
return new StreamTokenResponse(jwtService.generateStreamToken(authentication.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StreamTokenResponse(String token) {
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public SseEmitter stream(Authentication authentication) {
|
public SseEmitter stream(Authentication authentication) {
|
||||||
return notificationService.subscribe(authentication.getName());
|
return notificationService.subscribe(authentication.getName());
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pl.polskalokalnie.og;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.text.NumberFormat;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serwerowe renderowanie strony ogloszenia dla crawlerow social media (Facebook, WhatsApp,
|
||||||
|
* Messenger, Twitter/X...). Te boty nie wykonuja JavaScriptu, wiec meta tagi ustawiane dynamicznie
|
||||||
|
* w Reakcie sa dla nich niewidoczne - podglad linku bylby pusty. Nginx wykrywa crawlera po
|
||||||
|
* User-Agent i dla adresu /oferta/{id} kieruje go tutaj zamiast do aplikacji SPA; zwykly uzytkownik
|
||||||
|
* dostaje niezmieniona aplikacje. Zwracany HTML zawiera Open Graph/Twitter Card z tytulem, cena,
|
||||||
|
* metrazem, lokalizacja i glownym zdjeciem, a nastepnie przekierowuje do kanonicznego adresu SPA.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/og")
|
||||||
|
public class OgController {
|
||||||
|
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public OgController(ListingRepository listingRepository,
|
||||||
|
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
// Bez ukosnika na koncu - laczymy przez konkatenacje ze sciezka zaczynajaca sie od "/".
|
||||||
|
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@GetMapping(value = "/listing/{id}", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")
|
||||||
|
public ResponseEntity<String> listingPreview(@PathVariable Long id) {
|
||||||
|
String canonicalUrl = baseUrl + "/oferta/" + id;
|
||||||
|
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||||
|
|
||||||
|
// Nieopublikowane ogloszenia (oczekujace, wstrzymane, odrzucone) i nieistniejace nie wyciekaja
|
||||||
|
// danych - crawler dostaje ogolna wizytowke serwisu z przekierowaniem do aplikacji.
|
||||||
|
if (listing == null || listing.getStatus() != ListingStatus.APPROVED) {
|
||||||
|
String html = renderHtml(
|
||||||
|
"Polska Lokalnie",
|
||||||
|
"Ogloszenia sprzedazy i wynajmu mieszkan, domow oraz apartamentow.",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
canonicalUrl);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noCache())
|
||||||
|
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||||
|
.body(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
String location = formatLocation(listing);
|
||||||
|
// Opis podgladu = cena · metraz · lokalizacja - dokladnie to, co ma zobaczyc odbiorca linku.
|
||||||
|
String description = String.join(" · ", nonBlank(
|
||||||
|
formatPrice(listing),
|
||||||
|
formatArea(listing.getArea()),
|
||||||
|
location));
|
||||||
|
|
||||||
|
String imageUrl = hasImage(listing) ? baseUrl + "/api/og/listing/" + id + "/image" : null;
|
||||||
|
|
||||||
|
String html = renderHtml(listing.getTitle(), description, imageUrl, listing.getTitle(), canonicalUrl);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noCache())
|
||||||
|
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
|
||||||
|
.body(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glowne zdjecie ogloszenia jako realny obraz pod stabilnym adresem. Zdjecia trzymamy jako
|
||||||
|
* data URL (base64) - crawler nie potrafi ich uzyc jako og:image, wiec dekodujemy je tutaj do
|
||||||
|
* bajtow z prawidlowym Content-Type.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@GetMapping("/listing/{id}/image")
|
||||||
|
public ResponseEntity<byte[]> listingImage(@PathVariable Long id) {
|
||||||
|
PropertyListing listing = listingRepository.findById(id).orElse(null);
|
||||||
|
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
DecodedImage image = decodeDataUrl(listing.getPhotos().get(0));
|
||||||
|
if (image == null) {
|
||||||
|
// Bez przekierowania pod zapisany adres - to byloby otwarte przekierowanie na dowolna strone.
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
// nosniff + sandbox: nawet gdyby w bazie siedzial spreparowany plik, przegladarka nie wykona
|
||||||
|
// go jako strony w domenie serwisu.
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.valueOf(image.mimeType()))
|
||||||
|
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
|
||||||
|
.header("X-Content-Type-Options", "nosniff")
|
||||||
|
.header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||||
|
.body(image.data());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasImage(PropertyListing listing) {
|
||||||
|
List<String> photos = listing.getPhotos();
|
||||||
|
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
record DecodedImage(String mimeType, byte[] data) {}
|
||||||
|
|
||||||
|
// Tylko obrazy rastrowe - typ MIME pochodzi od uzytkownika i nie moze decydowac o tym,
|
||||||
|
// jako co przegladarka potraktuje odpowiedz (text/html, image/svg+xml = skrypt w domenie serwisu).
|
||||||
|
private static final java.util.Set<String> ALLOWED_IMAGE_TYPES = java.util.Set.of(
|
||||||
|
MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, "image/webp");
|
||||||
|
|
||||||
|
static DecodedImage decodeDataUrl(String value) {
|
||||||
|
if (value == null || !value.startsWith("data:")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int comma = value.indexOf(',');
|
||||||
|
if (comma < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String meta = value.substring(5, comma); // np. "image/jpeg;base64"
|
||||||
|
if (!meta.contains(";base64")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String mime = meta.substring(0, meta.indexOf(';'));
|
||||||
|
if (mime.isBlank()) {
|
||||||
|
mime = MediaType.IMAGE_JPEG_VALUE;
|
||||||
|
}
|
||||||
|
mime = mime.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.contains(mime)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
|
||||||
|
return new DecodedImage(mime, data);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Formatowanie zgodne z prezentacja na froncie (formatPln, "X m²", "district, city") ---
|
||||||
|
|
||||||
|
private static String formatPrice(PropertyListing listing) {
|
||||||
|
BigDecimal price = listing.getPrice();
|
||||||
|
if (price == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
NumberFormat format = NumberFormat.getIntegerInstance(new Locale("pl", "PL"));
|
||||||
|
String amount = format.format(price.setScale(0, RoundingMode.HALF_UP)).replace(' ', ' ');
|
||||||
|
String base = amount + " zł";
|
||||||
|
return listing.getOfferType() == OfferType.RENT ? base + " / mies." : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatArea(Double area) {
|
||||||
|
if (area == null || area <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (area == Math.floor(area)) {
|
||||||
|
return (long) area.doubleValue() + " m²";
|
||||||
|
}
|
||||||
|
return String.format(new Locale("pl", "PL"), "%.1f m²", area);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatLocation(PropertyListing listing) {
|
||||||
|
String city = listing.getCity() == null ? "" : listing.getCity().trim();
|
||||||
|
String district = listing.getDistrict() == null ? "" : listing.getDistrict().trim();
|
||||||
|
if (!district.isEmpty() && !city.isEmpty()) {
|
||||||
|
return district + ", " + city;
|
||||||
|
}
|
||||||
|
return city.isEmpty() ? district : city;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] nonBlank(String... values) {
|
||||||
|
return java.util.Arrays.stream(values).filter(v -> v != null && !v.isBlank()).toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String renderHtml(String title, String description, String imageUrl, String imageAlt, String canonicalUrl) {
|
||||||
|
String t = escapeHtml(title);
|
||||||
|
String d = escapeHtml(description);
|
||||||
|
StringBuilder head = new StringBuilder();
|
||||||
|
head.append("<meta property=\"og:type\" content=\"website\"/>\n");
|
||||||
|
head.append("<meta property=\"og:site_name\" content=\"Polska Lokalnie\"/>\n");
|
||||||
|
head.append("<meta property=\"og:locale\" content=\"pl_PL\"/>\n");
|
||||||
|
head.append("<meta property=\"og:title\" content=\"").append(t).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:description\" content=\"").append(d).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:url\" content=\"").append(escapeHtml(canonicalUrl)).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:card\" content=\"").append(imageUrl != null ? "summary_large_image" : "summary").append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:title\" content=\"").append(t).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:description\" content=\"").append(d).append("\"/>\n");
|
||||||
|
if (imageUrl != null) {
|
||||||
|
String img = escapeHtml(imageUrl);
|
||||||
|
head.append("<meta property=\"og:image\" content=\"").append(img).append("\"/>\n");
|
||||||
|
head.append("<meta property=\"og:image:alt\" content=\"").append(escapeHtml(imageAlt)).append("\"/>\n");
|
||||||
|
head.append("<meta name=\"twitter:image\" content=\"").append(img).append("\"/>\n");
|
||||||
|
}
|
||||||
|
return "<!doctype html>\n"
|
||||||
|
+ "<html lang=\"pl\">\n<head>\n"
|
||||||
|
+ "<meta charset=\"utf-8\"/>\n"
|
||||||
|
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
|
||||||
|
+ "<title>" + t + "</title>\n"
|
||||||
|
+ "<meta name=\"description\" content=\"" + d + "\"/>\n"
|
||||||
|
+ "<link rel=\"canonical\" href=\"" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||||
|
+ head
|
||||||
|
// Czlowiek, ktory trafi tu z linku (a nie bot), zostaje od razu przeniesiony do aplikacji.
|
||||||
|
+ "<meta http-equiv=\"refresh\" content=\"0; url=" + escapeHtml(canonicalUrl) + "\"/>\n"
|
||||||
|
+ "</head>\n<body>\n"
|
||||||
|
+ "<p><a href=\"" + escapeHtml(canonicalUrl) + "\">" + t + "</a></p>\n"
|
||||||
|
+ "<script>window.location.replace(" + jsString(canonicalUrl) + ");</script>\n"
|
||||||
|
+ "</body>\n</html>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeHtml(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bezpieczne osadzenie adresu w literale JS (redirect) - adres pochodzi z konfiguracji, ale
|
||||||
|
// trzymamy sie zasady, ze nic nie trafia do skryptu bez ucieczki.
|
||||||
|
private static String jsString(String value) {
|
||||||
|
String escaped = value == null ? "" : value
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("<", "\\u003C")
|
||||||
|
.replace(">", "\\u003E");
|
||||||
|
return "'" + escaped + "'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package pl.polskalokalnie.payment;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import javax.xml.XMLConstants;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.parsers.ParserConfigurationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.Node;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64).
|
||||||
|
*
|
||||||
|
* Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD
|
||||||
|
* i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo
|
||||||
|
* wymuszac zadania wychodzace (SSRF).
|
||||||
|
*/
|
||||||
|
public final class AutoPayItnParser {
|
||||||
|
|
||||||
|
private AutoPayItnParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */
|
||||||
|
public record ItnTransaction(
|
||||||
|
String orderId,
|
||||||
|
String remoteId,
|
||||||
|
String amount,
|
||||||
|
String currency,
|
||||||
|
String gatewayId,
|
||||||
|
String paymentDate,
|
||||||
|
String paymentStatus,
|
||||||
|
String paymentStatusDetails
|
||||||
|
) {
|
||||||
|
public boolean success() {
|
||||||
|
return "SUCCESS".equalsIgnoreCase(paymentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */
|
||||||
|
List<String> signedValues() {
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
values.add(orderId);
|
||||||
|
values.add(remoteId);
|
||||||
|
values.add(amount);
|
||||||
|
values.add(currency);
|
||||||
|
values.add(gatewayId);
|
||||||
|
values.add(paymentDate);
|
||||||
|
values.add(paymentStatus);
|
||||||
|
values.add(paymentStatusDetails);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */
|
||||||
|
public record Itn(String serviceId, List<ItnTransaction> transactions, String hash) {
|
||||||
|
|
||||||
|
/** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */
|
||||||
|
public List<String> signedValues() {
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
values.add(serviceId);
|
||||||
|
for (ItnTransaction tx : transactions) {
|
||||||
|
values.addAll(tx.signedValues());
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Itn parse(String xml) {
|
||||||
|
Document doc = parseSecurely(xml);
|
||||||
|
Element root = doc.getDocumentElement();
|
||||||
|
|
||||||
|
String serviceId = childText(root, "serviceID");
|
||||||
|
// Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji).
|
||||||
|
String hash = childText(root, "hash");
|
||||||
|
|
||||||
|
List<ItnTransaction> transactions = new ArrayList<>();
|
||||||
|
for (Element tx : elementsByTag(root, "transaction")) {
|
||||||
|
transactions.add(new ItnTransaction(
|
||||||
|
childText(tx, "orderID"),
|
||||||
|
childText(tx, "remoteID"),
|
||||||
|
childText(tx, "amount"),
|
||||||
|
childText(tx, "currency"),
|
||||||
|
childText(tx, "gatewayID"),
|
||||||
|
childText(tx, "paymentDate"),
|
||||||
|
childText(tx, "paymentStatus"),
|
||||||
|
childText(tx, "paymentStatusDetails")));
|
||||||
|
}
|
||||||
|
if (serviceId == null || transactions.isEmpty()) {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
return new Itn(serviceId, transactions, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Document parseSecurely(String xml) {
|
||||||
|
try {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||||
|
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||||
|
factory.setXIncludeAware(false);
|
||||||
|
factory.setExpandEntityReferences(false);
|
||||||
|
return factory.newDocumentBuilder()
|
||||||
|
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (ParserConfigurationException e) {
|
||||||
|
throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseStatusException invalid() {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */
|
||||||
|
private static String childText(Element parent, String tag) {
|
||||||
|
NodeList children = parent.getChildNodes();
|
||||||
|
for (int i = 0; i < children.getLength(); i++) {
|
||||||
|
Node node = children.item(i);
|
||||||
|
if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) {
|
||||||
|
String text = node.getTextContent();
|
||||||
|
return text == null || text.isBlank() ? null : text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Element> elementsByTag(Element root, String tag) {
|
||||||
|
List<Element> result = new ArrayList<>();
|
||||||
|
NodeList nodes = root.getElementsByTagName(tag);
|
||||||
|
for (int i = 0; i < nodes.getLength(); i++) {
|
||||||
|
result.add((Element) nodes.item(i));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,19 @@ package pl.polskalokalnie.payment;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
|
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego
|
||||||
|
* produkcyjnie w Gieldzie Komunalnej.
|
||||||
*
|
*
|
||||||
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
|
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
|
||||||
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
|
* parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola
|
||||||
*
|
* opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100,
|
||||||
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
|
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
|
||||||
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AutoPayService {
|
public class AutoPayService {
|
||||||
@@ -26,66 +22,38 @@ public class AutoPayService {
|
|||||||
private static final String PROD_URL = "https://pay.autopay.eu/payment";
|
private static final String PROD_URL = "https://pay.autopay.eu/payment";
|
||||||
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
|
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
|
||||||
|
|
||||||
/** Adres paywalla z podpisanymi parametrami transakcji. */
|
/**
|
||||||
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
|
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
|
||||||
String description, String customerEmail, String returnUrl) {
|
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
|
||||||
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
|
||||||
|
*/
|
||||||
// Kolejnosc parametrow ma znaczenie dla podpisu.
|
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
|
||||||
Map<String, String> params = new LinkedHashMap<>();
|
String description, String customerEmail) {
|
||||||
params.put("ServiceID", cfg.getServiceId());
|
String amountStr = formatAmount(amount);
|
||||||
params.put("OrderID", orderId);
|
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
|
||||||
params.put("Amount", amountStr);
|
customerEmail == null ? "" : customerEmail));
|
||||||
params.put("Currency", "PLN");
|
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
||||||
params.put("Description", description == null ? "" : description);
|
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
|
||||||
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
|
customerEmail, hash);
|
||||||
if (returnUrl != null && !returnUrl.isBlank()) {
|
|
||||||
params.put("ReturnURL", returnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
|
|
||||||
params.put("Hash", hash);
|
|
||||||
|
|
||||||
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
|
|
||||||
StringBuilder query = new StringBuilder();
|
|
||||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
|
||||||
if (query.length() > 0) {
|
|
||||||
query.append('&');
|
|
||||||
}
|
|
||||||
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
|
|
||||||
}
|
|
||||||
return base + "?" + query;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
|
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
|
||||||
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
|
public static String formatAmount(BigDecimal amount) {
|
||||||
if (params == null || cfg == null || cfg.getSecretKey() == null) {
|
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String provided = params.get("Hash");
|
|
||||||
if (provided == null || provided.isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
List<String> values = new ArrayList<>();
|
|
||||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
|
||||||
if (!"Hash".equalsIgnoreCase(e.getKey())) {
|
|
||||||
values.add(e.getValue());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
|
|
||||||
return MessageDigest.isEqual(
|
|
||||||
expected.getBytes(StandardCharsets.UTF_8),
|
|
||||||
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
|
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
|
||||||
public String computeHash(List<String> values, String separator, String key) {
|
public String hash(PaymentConfig cfg, List<String> values) {
|
||||||
List<String> parts = new ArrayList<>(values);
|
StringBuilder sb = new StringBuilder();
|
||||||
parts.add(key);
|
for (String value : values) {
|
||||||
String data = String.join(separator, parts);
|
if (value == null || value.isEmpty()) {
|
||||||
|
continue; // puste pole opcjonalne: pomijamy bez separatora
|
||||||
|
}
|
||||||
|
sb.append(value).append(cfg.getHashSeparator());
|
||||||
|
}
|
||||||
|
sb.append(cfg.getSecretKey());
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
|
|
||||||
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
StringBuilder hex = new StringBuilder(bytes.length * 2);
|
||||||
for (byte b : bytes) {
|
for (byte b : bytes) {
|
||||||
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||||
@@ -97,7 +65,16 @@ public class AutoPayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String enc(String value) {
|
/**
|
||||||
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
|
* Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
|
||||||
|
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
|
||||||
|
*/
|
||||||
|
public static boolean hashMatches(String expected, String actual) {
|
||||||
|
if (expected == null || actual == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MessageDigest.isEqual(
|
||||||
|
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
|
||||||
|
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package pl.polskalokalnie.payment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
|
||||||
|
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
|
||||||
|
*/
|
||||||
|
public record AutoPayStartForm(
|
||||||
|
String gatewayUrl,
|
||||||
|
String serviceId,
|
||||||
|
String orderId,
|
||||||
|
String amount,
|
||||||
|
String description,
|
||||||
|
String customerEmail,
|
||||||
|
String hash
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
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 pl.polskalokalnie.listing.PropertyType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
|
||||||
|
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
|
||||||
|
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
|
||||||
|
*
|
||||||
|
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "price_alerts")
|
||||||
|
public class PriceAlert {
|
||||||
|
|
||||||
|
public enum Kind {
|
||||||
|
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
|
||||||
|
LISTING,
|
||||||
|
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
|
||||||
|
LOCATION
|
||||||
|
}
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_email", nullable = false, length = 180)
|
||||||
|
private String userEmail;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private Kind kind;
|
||||||
|
|
||||||
|
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
// --- alert ogloszenia ---
|
||||||
|
|
||||||
|
@Column(name = "listing_id")
|
||||||
|
private Long listingId;
|
||||||
|
|
||||||
|
// --- alert lokalizacji ---
|
||||||
|
|
||||||
|
@Column(length = 120)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
|
||||||
|
@Column(length = 60)
|
||||||
|
private String region;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "property_type", length = 20)
|
||||||
|
private PropertyType propertyType;
|
||||||
|
|
||||||
|
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
|
||||||
|
@Column(length = 20)
|
||||||
|
private String market;
|
||||||
|
|
||||||
|
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
|
||||||
|
@Column(name = "last_value")
|
||||||
|
private Long lastValue;
|
||||||
|
|
||||||
|
@Column(name = "last_checked_at")
|
||||||
|
private Instant lastCheckedAt;
|
||||||
|
|
||||||
|
@Column(name = "notifications_enabled", nullable = false)
|
||||||
|
private boolean notificationsEnabled = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserEmail() {
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserEmail(String userEmail) {
|
||||||
|
this.userEmail = userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Kind getKind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKind(Kind kind) {
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getListingId() {
|
||||||
|
return listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListingId(Long listingId) {
|
||||||
|
this.listingId = listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCity() {
|
||||||
|
return city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCity(String city) {
|
||||||
|
this.city = city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRegion() {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRegion(String region) {
|
||||||
|
this.region = region;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PropertyType getPropertyType() {
|
||||||
|
return propertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPropertyType(PropertyType propertyType) {
|
||||||
|
this.propertyType = propertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMarket() {
|
||||||
|
return market;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMarket(String market) {
|
||||||
|
this.market = market;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getLastValue() {
|
||||||
|
return lastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastValue(Long lastValue) {
|
||||||
|
this.lastValue = lastValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getLastCheckedAt() {
|
||||||
|
return lastCheckedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastCheckedAt(Instant lastCheckedAt) {
|
||||||
|
this.lastCheckedAt = lastCheckedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotificationsEnabled() {
|
||||||
|
return notificationsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotificationsEnabled(boolean notificationsEnabled) {
|
||||||
|
this.notificationsEnabled = notificationsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
|
||||||
|
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
|
||||||
|
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PriceAlertChecker {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
|
||||||
|
|
||||||
|
private final PriceAlertService priceAlertService;
|
||||||
|
|
||||||
|
public PriceAlertChecker(PriceAlertService priceAlertService) {
|
||||||
|
this.priceAlertService = priceAlertService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
public void checkDaily() {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
|
||||||
|
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
|
||||||
|
public void checkOnStartup() {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run() {
|
||||||
|
try {
|
||||||
|
int notified = priceAlertService.checkAll();
|
||||||
|
if (notified > 0) {
|
||||||
|
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
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.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
|
||||||
|
|
||||||
|
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/price-alerts")
|
||||||
|
public class PriceAlertController {
|
||||||
|
|
||||||
|
private final PriceAlertService priceAlertService;
|
||||||
|
|
||||||
|
public PriceAlertController(PriceAlertService priceAlertService) {
|
||||||
|
this.priceAlertService = priceAlertService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<PriceAlertResponse> list(Authentication authentication) {
|
||||||
|
return priceAlertService.list(authentication.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
|
||||||
|
@GetMapping("/events")
|
||||||
|
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
|
||||||
|
return priceAlertService.events(authentication.getName(), days);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
|
||||||
|
return priceAlertService.create(authentication.getName(), request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NotificationsRequest(boolean enabled) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/notifications")
|
||||||
|
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
|
||||||
|
priceAlertService.delete(authentication.getName(), id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public final class PriceAlertDtos {
|
||||||
|
|
||||||
|
private PriceAlertDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
|
||||||
|
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
|
||||||
|
*/
|
||||||
|
public record PriceAlertRequest(Long listingId, String label, String city, String region,
|
||||||
|
String propertyType, String market) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
|
||||||
|
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
|
||||||
|
*/
|
||||||
|
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
|
||||||
|
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
|
||||||
|
boolean notificationsEnabled, Instant createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
|
||||||
|
long previousValue, long currentValue, Instant changedAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
|
||||||
|
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "price_alert_events")
|
||||||
|
public class PriceAlertEvent {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "alert_id", nullable = false)
|
||||||
|
private Long alertId;
|
||||||
|
|
||||||
|
@Column(name = "user_email", nullable = false, length = 180)
|
||||||
|
private String userEmail;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private PriceAlert.Kind kind;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
@Column(name = "listing_id")
|
||||||
|
private Long listingId;
|
||||||
|
|
||||||
|
@Column(name = "previous_value", nullable = false)
|
||||||
|
private long previousValue;
|
||||||
|
|
||||||
|
@Column(name = "current_value", nullable = false)
|
||||||
|
private long currentValue;
|
||||||
|
|
||||||
|
@Column(name = "changed_at", nullable = false)
|
||||||
|
private Instant changedAt = Instant.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getAlertId() {
|
||||||
|
return alertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAlertId(Long alertId) {
|
||||||
|
this.alertId = alertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserEmail() {
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserEmail(String userEmail) {
|
||||||
|
this.userEmail = userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PriceAlert.Kind getKind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKind(PriceAlert.Kind kind) {
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getListingId() {
|
||||||
|
return listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListingId(Long listingId) {
|
||||||
|
this.listingId = listingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPreviousValue() {
|
||||||
|
return previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPreviousValue(long previousValue) {
|
||||||
|
this.previousValue = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getCurrentValue() {
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentValue(long currentValue) {
|
||||||
|
this.currentValue = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getChangedAt() {
|
||||||
|
return changedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
|
||||||
|
|
||||||
|
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
|
||||||
|
|
||||||
|
void deleteByAlertId(Long alertId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
|
||||||
|
|
||||||
|
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||||
|
|
||||||
|
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
|
||||||
|
|
||||||
|
List<PriceAlert> findByKind(PriceAlert.Kind kind);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package pl.polskalokalnie.pricealert;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
|
||||||
|
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
|
||||||
|
import pl.polskalokalnie.pricehistory.PriceSnapshotService;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
import pl.polskalokalnie.user.UserSettings;
|
||||||
|
import pl.polskalokalnie.user.UserSettingsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
|
||||||
|
*
|
||||||
|
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
|
||||||
|
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
|
||||||
|
* ruchu pocztowego.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PriceAlertService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
|
||||||
|
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
|
||||||
|
* zmiana jest dla obserwujacego istotna.
|
||||||
|
*/
|
||||||
|
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
|
||||||
|
|
||||||
|
private final PriceAlertRepository alertRepository;
|
||||||
|
private final PriceAlertEventRepository eventRepository;
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final PriceSnapshotService snapshotService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserSettingsRepository userSettingsRepository;
|
||||||
|
|
||||||
|
public PriceAlertService(PriceAlertRepository alertRepository,
|
||||||
|
PriceAlertEventRepository eventRepository,
|
||||||
|
ListingRepository listingRepository,
|
||||||
|
PriceSnapshotService snapshotService,
|
||||||
|
NotificationService notificationService,
|
||||||
|
UserRepository userRepository,
|
||||||
|
UserSettingsRepository userSettingsRepository) {
|
||||||
|
this.alertRepository = alertRepository;
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
this.snapshotService = snapshotService;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userSettingsRepository = userSettingsRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globalny przelacznik "Alerty cenowe" z ustawien konta. Wylaczony wycisza wszystkie alerty
|
||||||
|
* naraz; przelacznik przy pojedynczym alercie dziala niezaleznie i wycisza tylko jego.
|
||||||
|
* Historia zmian zapisuje sie tak czy inaczej - wyciszamy powiadomienie, nie pomiar.
|
||||||
|
*/
|
||||||
|
private boolean wantsAlertNotifications(String userEmail) {
|
||||||
|
return userRepository.findByEmailIgnoreCase(userEmail)
|
||||||
|
.flatMap(user -> userSettingsRepository.findByUserId(user.getId()))
|
||||||
|
.map(UserSettings::isNotifyPriceAlerts)
|
||||||
|
.orElse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PriceAlertResponse> list(String userEmail) {
|
||||||
|
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||||
|
.map(PriceAlertService::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<PriceAlertEventResponse> events(String userEmail, int days) {
|
||||||
|
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
|
||||||
|
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
|
||||||
|
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
|
||||||
|
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
|
||||||
|
event.getChangedAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
|
||||||
|
}
|
||||||
|
PriceAlert alert = request.listingId() != null
|
||||||
|
? buildListingAlert(userEmail, request.listingId())
|
||||||
|
: buildLocationAlert(userEmail, request);
|
||||||
|
alert.setLastValue(measure(alert));
|
||||||
|
alert.setLastCheckedAt(Instant.now());
|
||||||
|
return toResponse(alertRepository.save(alert));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
|
||||||
|
PriceAlert alert = requireOwn(userEmail, id);
|
||||||
|
alert.setNotificationsEnabled(enabled);
|
||||||
|
return toResponse(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(String userEmail, Long id) {
|
||||||
|
PriceAlert alert = requireOwn(userEmail, id);
|
||||||
|
eventRepository.deleteByAlertId(alert.getId());
|
||||||
|
alertRepository.delete(alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
|
||||||
|
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int checkAll() {
|
||||||
|
int notified = 0;
|
||||||
|
for (PriceAlert alert : alertRepository.findAll()) {
|
||||||
|
try {
|
||||||
|
if (checkOne(alert)) {
|
||||||
|
notified++;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return notified;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean checkOne(PriceAlert alert) {
|
||||||
|
Long current = measure(alert);
|
||||||
|
if (current == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long previous = alert.getLastValue();
|
||||||
|
alert.setLastValue(current);
|
||||||
|
alert.setLastCheckedAt(Instant.now());
|
||||||
|
if (previous == null || previous <= 0 || previous.equals(current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double changePct = ((current - previous) * 100.0) / previous;
|
||||||
|
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PriceAlertEvent event = new PriceAlertEvent();
|
||||||
|
event.setAlertId(alert.getId());
|
||||||
|
event.setUserEmail(alert.getUserEmail());
|
||||||
|
event.setKind(alert.getKind());
|
||||||
|
event.setLabel(alert.getLabel());
|
||||||
|
event.setListingId(alert.getListingId());
|
||||||
|
event.setPreviousValue(previous);
|
||||||
|
event.setCurrentValue(current);
|
||||||
|
eventRepository.save(event);
|
||||||
|
|
||||||
|
if (alert.isNotificationsEnabled() && wantsAlertNotifications(alert.getUserEmail())) {
|
||||||
|
notify(alert, previous, current, changePct);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notify(PriceAlert alert, long previous, long current, double changePct) {
|
||||||
|
boolean down = current < previous;
|
||||||
|
String direction = down ? "spadła" : "wzrosła";
|
||||||
|
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
|
||||||
|
? "listing:" + alert.getListingId()
|
||||||
|
: "priceAlerts";
|
||||||
|
String title = alert.getKind() == PriceAlert.Kind.LISTING
|
||||||
|
? "Cena " + direction + ": " + alert.getLabel()
|
||||||
|
: "Zmiana cen: " + alert.getLabel();
|
||||||
|
String body = alert.getKind() == PriceAlert.Kind.LISTING
|
||||||
|
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
|
||||||
|
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
|
||||||
|
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
|
||||||
|
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
|
||||||
|
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
|
||||||
|
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
|
||||||
|
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
|
||||||
|
title, body, link, dedupeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
|
||||||
|
private Long measure(PriceAlert alert) {
|
||||||
|
if (alert.getKind() == PriceAlert.Kind.LISTING) {
|
||||||
|
return listingRepository.findById(alert.getListingId())
|
||||||
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
|
.map(PropertyListing::getPrice)
|
||||||
|
.map(price -> price.longValue())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
PriceSnapshotService.Average average = snapshotService.currentAverage(
|
||||||
|
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
|
||||||
|
alert.getCity(), "");
|
||||||
|
return average == null ? null : average.avgPricePerM2();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
|
||||||
|
PropertyListing listing = listingRepository.findById(listingId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
|
||||||
|
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
PriceAlert alert = new PriceAlert();
|
||||||
|
alert.setUserEmail(userEmail);
|
||||||
|
alert.setKind(PriceAlert.Kind.LISTING);
|
||||||
|
alert.setListingId(listingId);
|
||||||
|
alert.setLabel(listing.getTitle());
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
|
||||||
|
if (request.city() == null || request.city().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
|
||||||
|
}
|
||||||
|
String city = request.city().trim();
|
||||||
|
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
|
||||||
|
String market = request.market() == null ? "" : request.market().trim();
|
||||||
|
|
||||||
|
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
|
||||||
|
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
|
||||||
|
.filter(item -> city.equalsIgnoreCase(item.getCity()))
|
||||||
|
.filter(item -> item.getPropertyType() == propertyType)
|
||||||
|
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseGet(PriceAlert::new);
|
||||||
|
alert.setUserEmail(userEmail);
|
||||||
|
alert.setKind(PriceAlert.Kind.LOCATION);
|
||||||
|
alert.setCity(city);
|
||||||
|
alert.setRegion(request.region() == null ? null : request.region().trim());
|
||||||
|
alert.setPropertyType(propertyType);
|
||||||
|
alert.setMarket(market);
|
||||||
|
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PriceAlert requireOwn(String userEmail, Long id) {
|
||||||
|
return alertRepository.findById(id)
|
||||||
|
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String money(long value) {
|
||||||
|
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PriceAlertResponse toResponse(PriceAlert alert) {
|
||||||
|
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
|
||||||
|
alert.getCity(), alert.getRegion(),
|
||||||
|
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
|
||||||
|
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
|
||||||
|
alert.isNotificationsEnabled(), alert.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import cen transakcyjnych z NBP (plik XLSX) i GUS (API BDL) - zrodla porownawcze dla historii cen.
|
||||||
|
* Raz w tygodniu oraz przy starcie, gdy dane sa starsze niz tydzien. Blad zrodla nie rusza starych danych.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ExternalPriceImportService {
|
||||||
|
|
||||||
|
static final String NBP_URL = "https://static.nbp.pl/dane/rynek-nieruchomosci/ceny_mieszkan.xlsx";
|
||||||
|
private static final long MAX_DOWNLOAD_BYTES = 20L * 1024 * 1024;
|
||||||
|
private static final int GUS_FROM_YEAR = 2010;
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExternalPriceImportService.class);
|
||||||
|
|
||||||
|
private final ExternalQuarterPriceRepository repository;
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final GusBdlClient gusClient;
|
||||||
|
private final TransactionTemplate transaction;
|
||||||
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public ExternalPriceImportService(ExternalQuarterPriceRepository repository, ListingRepository listingRepository,
|
||||||
|
GusBdlClient gusClient, TransactionTemplate transaction) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
this.gusClient = gusClient;
|
||||||
|
this.transaction = transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void importOnStartupIfStale() {
|
||||||
|
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||||
|
if (repository.findLastImport(Source.NBP).map(last -> last.isBefore(weekAgo)).orElse(true)) {
|
||||||
|
importNbp();
|
||||||
|
}
|
||||||
|
if (repository.findLastImport(Source.GUS).map(last -> last.isBefore(weekAgo)).orElse(true)) {
|
||||||
|
importGus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 10 4 * * MON", zone = "Europe/Warsaw")
|
||||||
|
public void importWeekly() {
|
||||||
|
importNbp();
|
||||||
|
importGus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int importNbp() {
|
||||||
|
try {
|
||||||
|
List<NbpXlsxParser.Row> rows;
|
||||||
|
try (InputStream xlsx = download(NBP_URL)) {
|
||||||
|
rows = NbpXlsxParser.parse(xlsx);
|
||||||
|
}
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
log.warn("NBP: w pliku nie znaleziono cen transakcyjnych - zostawiam poprzednie dane");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
transaction.executeWithoutResult(status -> {
|
||||||
|
repository.deleteBySource(Source.NBP);
|
||||||
|
repository.saveAll(rows.stream()
|
||||||
|
.map(row -> new ExternalQuarterPrice(Source.NBP, row.year(), row.quarter(), row.market(),
|
||||||
|
row.city(), row.pricePerM2(), now))
|
||||||
|
.toList());
|
||||||
|
});
|
||||||
|
log.info("NBP: zaimportowano {} cen kwartalnych", rows.size());
|
||||||
|
return rows.size();
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
log.warn("NBP: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
|
||||||
|
return 0;
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GUS dla miast z ogloszen i miast z danych NBP - tylko miasta na prawach powiatu maja wlasne dane. */
|
||||||
|
public int importGus() {
|
||||||
|
int imported = 0;
|
||||||
|
try {
|
||||||
|
List<GusBdlClient.Variable> variables = gusClient.variables();
|
||||||
|
int toYear = PriceSnapshotService.today().getYear();
|
||||||
|
for (String city : citiesToTrack()) {
|
||||||
|
try {
|
||||||
|
String unitId = gusClient.cityUnitId(city);
|
||||||
|
if (unitId != null) {
|
||||||
|
List<GusBdlClient.QuarterValue> values = gusClient.quarterlyPrices(unitId, variables, GUS_FROM_YEAR, toYear);
|
||||||
|
if (!values.isEmpty()) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
transaction.executeWithoutResult(status -> {
|
||||||
|
repository.deleteBySourceAndCityKey(Source.GUS, PriceSnapshotService.key(city));
|
||||||
|
repository.saveAll(values.stream()
|
||||||
|
.map(value -> new ExternalQuarterPrice(Source.GUS, value.year(), value.quarter(),
|
||||||
|
value.market(), city, value.pricePerM2(), now))
|
||||||
|
.toList());
|
||||||
|
});
|
||||||
|
imported += values.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Publiczne API bez klucza ma limity zapytan - nie spieszymy sie.
|
||||||
|
Thread.sleep(700);
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
log.warn("GUS: nie udalo sie pobrac danych dla {} ({})", city, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("GUS: zaimportowano {} cen kwartalnych", imported);
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
log.warn("GUS: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> citiesToTrack() {
|
||||||
|
Map<String, String> cities = new LinkedHashMap<>();
|
||||||
|
repository.findAll().stream()
|
||||||
|
.filter(price -> price.getSource() == Source.NBP)
|
||||||
|
.forEach(price -> cities.putIfAbsent(price.getCityKey(), price.getCity()));
|
||||||
|
listingRepository.findAll().stream()
|
||||||
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
|
.map(PropertyListing::getCity)
|
||||||
|
.filter(city -> city != null && !city.isBlank())
|
||||||
|
.forEach(city -> cities.putIfAbsent(PriceSnapshotService.key(city), city.trim()));
|
||||||
|
return List.copyOf(cities.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private InputStream download(String url) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).GET().build();
|
||||||
|
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("serwer odpowiedzial " + response.statusCode());
|
||||||
|
}
|
||||||
|
if (response.body().length > MAX_DOWNLOAD_BYTES) {
|
||||||
|
throw new IOException("plik jest za duzy");
|
||||||
|
}
|
||||||
|
return new java.io.ByteArrayInputStream(response.body());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
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 jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kwartalna srednia cena transakcyjna za m2 mieszkan z zewnetrznego zrodla:
|
||||||
|
* NBP (baza BaRN, 16 miast wojewodzkich i Gdynia) albo GUS (Bank Danych Lokalnych, miasta na prawach powiatu).
|
||||||
|
* Rynek: "Pierwotny", "Wtórny" albo "" (oba razem - tylko GUS).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "external_quarter_prices",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "year", "quarter", "market", "cityKey"}))
|
||||||
|
public class ExternalQuarterPrice {
|
||||||
|
|
||||||
|
public enum Source { NBP, GUS }
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 8)
|
||||||
|
private Source source;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int year;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int quarter;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private String market;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String cityKey;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private long pricePerM2;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant importedAt;
|
||||||
|
|
||||||
|
protected ExternalQuarterPrice() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExternalQuarterPrice(Source source, int year, int quarter, String market, String city, long pricePerM2,
|
||||||
|
Instant importedAt) {
|
||||||
|
this.source = source;
|
||||||
|
this.year = year;
|
||||||
|
this.quarter = quarter;
|
||||||
|
this.market = market;
|
||||||
|
this.city = city;
|
||||||
|
this.cityKey = PriceSnapshotService.key(city);
|
||||||
|
this.pricePerM2 = pricePerM2;
|
||||||
|
this.importedAt = importedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Source getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getYear() {
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQuarter() {
|
||||||
|
return quarter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMarket() {
|
||||||
|
return market;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCity() {
|
||||||
|
return city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCityKey() {
|
||||||
|
return cityKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPricePerM2() {
|
||||||
|
return pricePerM2;
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
|
||||||
|
|
||||||
|
public interface ExternalQuarterPriceRepository extends JpaRepository<ExternalQuarterPrice, Long> {
|
||||||
|
|
||||||
|
List<ExternalQuarterPrice> findByCityKeyAndMarketOrderByYearAscQuarterAsc(String cityKey, String market);
|
||||||
|
|
||||||
|
@Query("select max(p.importedAt) from ExternalQuarterPrice p where p.source = :source")
|
||||||
|
Optional<Instant> findLastImport(@Param("source") Source source);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("delete from ExternalQuarterPrice p where p.source = :source")
|
||||||
|
void deleteBySource(@Param("source") Source source);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("delete from ExternalQuarterPrice p where p.source = :source and p.cityKey = :cityKey")
|
||||||
|
void deleteBySourceAndCityKey(@Param("source") Source source, @Param("cityKey") String cityKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publiczne API GUS - Bank Danych Lokalnych (bdl.stat.gov.pl, bez klucza).
|
||||||
|
* Temat P3794: srednia cena za 1 m2 lokali mieszkalnych sprzedanych w ramach transakcji rynkowych
|
||||||
|
* (dane kwartalne), poziom powiatu - miasta na prawach powiatu maja wlasne dane.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class GusBdlClient {
|
||||||
|
|
||||||
|
static final String SUBJECT = "P3794";
|
||||||
|
private static final String API = "https://bdl.stat.gov.pl/api/v1";
|
||||||
|
private static final Pattern QUARTER = Pattern.compile("^(\\d)\\s*kwarta");
|
||||||
|
|
||||||
|
/** Zmienna BDL: kwartal i rynek ("" = oba rynki razem). */
|
||||||
|
public record Variable(long id, int quarter, String market) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record QuarterValue(int year, int quarter, String market, long pricePerM2) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/** Id jednostki "Powiat m. <miasto>" / "Powiat m.st. <miasto>" albo null, gdy miasto nie jest na prawach powiatu. */
|
||||||
|
public String cityUnitId(String city) throws IOException, InterruptedException {
|
||||||
|
JsonNode results = get("/units/search?level=5&format=json&page-size=20&name="
|
||||||
|
+ URLEncoder.encode(city, StandardCharsets.UTF_8)).path("results");
|
||||||
|
return matchCityUnit(results, city);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */
|
||||||
|
static final long WAGE_VARIABLE_ID = 64428;
|
||||||
|
|
||||||
|
public record AnnualValue(int year, java.math.BigDecimal value) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */
|
||||||
|
public AnnualValue latestAverageWage() throws IOException, InterruptedException {
|
||||||
|
JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100");
|
||||||
|
return parseLatestAnnualValue(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException {
|
||||||
|
AnnualValue latest = null;
|
||||||
|
for (JsonNode result : response.path("results")) {
|
||||||
|
for (JsonNode value : result.path("values")) {
|
||||||
|
int year = value.path("year").asInt(0);
|
||||||
|
java.math.BigDecimal amount = value.path("val").decimalValue();
|
||||||
|
if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) {
|
||||||
|
latest = new AnnualValue(year, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (latest == null) {
|
||||||
|
throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia");
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Variable> variables() throws IOException, InterruptedException {
|
||||||
|
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<QuarterValue> quarterlyPrices(String unitId, List<Variable> variables, int fromYear, int toYear)
|
||||||
|
throws IOException, InterruptedException {
|
||||||
|
StringBuilder query = new StringBuilder("/data/by-unit/").append(unitId).append("?format=json");
|
||||||
|
variables.forEach(variable -> query.append("&var-id=").append(variable.id()));
|
||||||
|
for (int year = fromYear; year <= toYear; year++) {
|
||||||
|
query.append("&year=").append(year);
|
||||||
|
}
|
||||||
|
return parseUnitData(get(query.toString()), variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String matchCityUnit(JsonNode results, String city) {
|
||||||
|
String wanted = normalize(city);
|
||||||
|
for (JsonNode unit : results) {
|
||||||
|
String name = normalize(unit.path("name").asText());
|
||||||
|
// "Powiat m. Kraków", "Powiat m. st. Warszawa" - bez powiatow ziemskich wokol miasta.
|
||||||
|
if (name.equals("powiat m. " + wanted) || name.equals("powiat m. st. " + wanted)
|
||||||
|
|| name.equals("powiat m.st. " + wanted)) {
|
||||||
|
return unit.path("id").asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tylko "ogolem" wg powierzchni lokalu - dla kazdego kwartalu i rynku jedna wartosc.
|
||||||
|
static List<Variable> parseVariables(JsonNode results) {
|
||||||
|
List<Variable> variables = new ArrayList<>();
|
||||||
|
for (JsonNode variable : results) {
|
||||||
|
Matcher quarter = QUARTER.matcher(variable.path("n1").asText());
|
||||||
|
String market = switch (normalize(variable.path("n2").asText())) {
|
||||||
|
case "ogółem" -> "";
|
||||||
|
case "rynek pierwotny" -> "Pierwotny";
|
||||||
|
case "rynek wtórny" -> "Wtórny";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (quarter.find() && market != null && "ogółem".equals(normalize(variable.path("n3").asText()))) {
|
||||||
|
variables.add(new Variable(variable.path("id").asLong(), Integer.parseInt(quarter.group(1)), market));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<QuarterValue> parseUnitData(JsonNode response, List<Variable> variables) {
|
||||||
|
Map<Long, Variable> byId = new LinkedHashMap<>();
|
||||||
|
variables.forEach(variable -> byId.put(variable.id(), variable));
|
||||||
|
List<QuarterValue> values = new ArrayList<>();
|
||||||
|
for (JsonNode result : response.path("results")) {
|
||||||
|
Variable variable = byId.get(result.path("id").asLong());
|
||||||
|
if (variable == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (JsonNode value : result.path("values")) {
|
||||||
|
double price = value.path("val").asDouble(0);
|
||||||
|
int year = value.path("year").asInt(0);
|
||||||
|
if (price > 0 && year > 0) {
|
||||||
|
values.add(new QuarterValue(year, variable.quarter(), variable.market(), Math.round(price)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode get(String path) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(API + path))
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IOException("GUS BDL odpowiedzial " + response.statusCode() + " dla " + path);
|
||||||
|
}
|
||||||
|
return mapper.readTree(response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
return Normalizer.normalize(value == null ? "" : value.trim(), Normalizer.Form.NFC)
|
||||||
|
.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
import javax.xml.XMLConstants;
|
||||||
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.Element;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odczyt cen transakcyjnych z pliku NBP ceny_mieszkan.xlsx bez zewnetrznych bibliotek (XLSX to ZIP z XML).
|
||||||
|
* Z arkuszy "Rynek pierwotny" i "Rynek wtórny" bierze blok "Ceny transakcyjne": kolumne kwartalu
|
||||||
|
* ("II 2026") i kolumny miast; pomija srednie zbiorcze ("7 miast") oraz indeksy cen.
|
||||||
|
*/
|
||||||
|
public final class NbpXlsxParser {
|
||||||
|
|
||||||
|
public record Row(int year, int quarter, String market, String city, long pricePerM2) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final long MAX_ENTRY_BYTES = 20L * 1024 * 1024;
|
||||||
|
private static final Pattern QUARTER = Pattern.compile("^(IV|III|II|I)\\s+(\\d{4})$");
|
||||||
|
private static final Pattern CELL_REF = Pattern.compile("^([A-Z]+)\\d+$");
|
||||||
|
private static final Map<String, String> SHEET_MARKETS = Map.of(
|
||||||
|
"Rynek pierwotny", "Pierwotny",
|
||||||
|
"Rynek wtórny", "Wtórny");
|
||||||
|
|
||||||
|
private NbpXlsxParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Row> parse(InputStream xlsx) throws IOException {
|
||||||
|
Map<String, byte[]> entries = unzip(xlsx);
|
||||||
|
try {
|
||||||
|
List<String> sharedStrings = sharedStrings(entries.get("xl/sharedStrings.xml"));
|
||||||
|
Map<String, String> sheetFiles = sheetFiles(entries);
|
||||||
|
List<Row> rows = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, String> sheet : SHEET_MARKETS.entrySet()) {
|
||||||
|
String file = sheetFiles.get(sheet.getKey());
|
||||||
|
if (file != null && entries.containsKey(file)) {
|
||||||
|
rows.addAll(transactionPrices(read(entries.get(file)), sharedStrings, sheet.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IOException("Nie udało się odczytać pliku NBP: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Row> transactionPrices(Document sheet, List<String> shared, String market) {
|
||||||
|
List<Map<String, String>> table = new ArrayList<>();
|
||||||
|
NodeList rowNodes = sheet.getElementsByTagName("row");
|
||||||
|
for (int i = 0; i < rowNodes.getLength(); i++) {
|
||||||
|
Map<String, String> cells = new LinkedHashMap<>();
|
||||||
|
NodeList cellNodes = ((Element) rowNodes.item(i)).getElementsByTagName("c");
|
||||||
|
for (int j = 0; j < cellNodes.getLength(); j++) {
|
||||||
|
Element cell = (Element) cellNodes.item(j);
|
||||||
|
Matcher ref = CELL_REF.matcher(cell.getAttribute("r"));
|
||||||
|
String value = cellValue(cell, shared);
|
||||||
|
if (ref.matches() && value != null && !value.isBlank()) {
|
||||||
|
cells.put(ref.group(1), value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.add(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
String transactionColumn = findColumn(table, "Ceny transakcyjne");
|
||||||
|
Map<String, String> header = table.stream()
|
||||||
|
.filter(row -> row.values().stream().filter("Kwartał"::equals).count() >= 2)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (transactionColumn == null || header == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
// Kolumna kwartalu bloku transakcyjnego: ostatnia "Kwartał" nie dalej niz naglowek bloku.
|
||||||
|
String quarterColumn = null;
|
||||||
|
for (Map.Entry<String, String> cell : header.entrySet()) {
|
||||||
|
if ("Kwartał".equals(cell.getValue()) && columnIndex(cell.getKey()) <= columnIndex(transactionColumn)) {
|
||||||
|
quarterColumn = cell.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (quarterColumn == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<String, String> cityColumns = new LinkedHashMap<>();
|
||||||
|
for (int index = columnIndex(quarterColumn) + 1; ; index++) {
|
||||||
|
String name = header.get(columnName(index));
|
||||||
|
if (name == null || name.toLowerCase().contains("miast")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cityColumns.put(columnName(index), name.replace("*", "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Row> result = new ArrayList<>();
|
||||||
|
for (Map<String, String> row : table) {
|
||||||
|
Matcher quarter = QUARTER.matcher(row.getOrDefault(quarterColumn, ""));
|
||||||
|
if (!quarter.matches()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int quarterNumber = switch (quarter.group(1)) {
|
||||||
|
case "I" -> 1;
|
||||||
|
case "II" -> 2;
|
||||||
|
case "III" -> 3;
|
||||||
|
default -> 4;
|
||||||
|
};
|
||||||
|
int year = Integer.parseInt(quarter.group(2));
|
||||||
|
cityColumns.forEach((column, city) -> {
|
||||||
|
Double price = number(row.get(column));
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
result.add(new Row(year, quarterNumber, market, city, Math.round(price)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String findColumn(List<Map<String, String>> table, String text) {
|
||||||
|
for (Map<String, String> row : table) {
|
||||||
|
for (Map.Entry<String, String> cell : row.entrySet()) {
|
||||||
|
if (text.equals(cell.getValue())) {
|
||||||
|
return cell.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cellValue(Element cell, List<String> shared) {
|
||||||
|
String type = cell.getAttribute("t");
|
||||||
|
if ("inlineStr".equals(type)) {
|
||||||
|
return textOf(cell);
|
||||||
|
}
|
||||||
|
NodeList values = cell.getElementsByTagName("v");
|
||||||
|
if (values.getLength() == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String raw = values.item(0).getTextContent();
|
||||||
|
if ("s".equals(type)) {
|
||||||
|
int index = Integer.parseInt(raw.trim());
|
||||||
|
return index < shared.size() ? shared.get(index) : null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sharedStrings(byte[] xml) throws Exception {
|
||||||
|
List<String> strings = new ArrayList<>();
|
||||||
|
if (xml == null) {
|
||||||
|
return strings;
|
||||||
|
}
|
||||||
|
NodeList items = read(xml).getElementsByTagName("si");
|
||||||
|
for (int i = 0; i < items.getLength(); i++) {
|
||||||
|
strings.add(textOf((Element) items.item(i)));
|
||||||
|
}
|
||||||
|
return strings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOf(Element element) {
|
||||||
|
StringBuilder text = new StringBuilder();
|
||||||
|
NodeList parts = element.getElementsByTagName("t");
|
||||||
|
for (int i = 0; i < parts.getLength(); i++) {
|
||||||
|
text.append(parts.item(i).getTextContent());
|
||||||
|
}
|
||||||
|
return text.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nazwa arkusza -> sciezka pliku w ZIP (workbook.xml + relacje).
|
||||||
|
private static Map<String, String> sheetFiles(Map<String, byte[]> entries) throws Exception {
|
||||||
|
Map<String, String> targets = new HashMap<>();
|
||||||
|
NodeList relations = read(entries.get("xl/_rels/workbook.xml.rels")).getElementsByTagName("Relationship");
|
||||||
|
for (int i = 0; i < relations.getLength(); i++) {
|
||||||
|
Element relation = (Element) relations.item(i);
|
||||||
|
String target = relation.getAttribute("Target");
|
||||||
|
targets.put(relation.getAttribute("Id"), target.startsWith("/") ? target.substring(1) : "xl/" + target);
|
||||||
|
}
|
||||||
|
Map<String, String> files = new HashMap<>();
|
||||||
|
NodeList sheets = read(entries.get("xl/workbook.xml")).getElementsByTagName("sheet");
|
||||||
|
for (int i = 0; i < sheets.getLength(); i++) {
|
||||||
|
Element sheet = (Element) sheets.item(i);
|
||||||
|
files.put(sheet.getAttribute("name").trim(), targets.get(sheet.getAttribute("r:id")));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, byte[]> unzip(InputStream input) throws IOException {
|
||||||
|
Map<String, byte[]> entries = new HashMap<>();
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(input)) {
|
||||||
|
ZipEntry entry;
|
||||||
|
while ((entry = zip.getNextEntry()) != null) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byte[] data = zip.readNBytes((int) MAX_ENTRY_BYTES + 1);
|
||||||
|
if (data.length > MAX_ENTRY_BYTES) {
|
||||||
|
throw new IOException("Plik NBP ma nieoczekiwanie duzy element: " + entry.getName());
|
||||||
|
}
|
||||||
|
entries.put(entry.getName(), data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!entries.containsKey("xl/workbook.xml")) {
|
||||||
|
throw new IOException("To nie jest plik XLSX");
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Document read(byte[] xml) throws Exception {
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
// Plik z zewnatrz - bez encji zewnetrznych i DTD (ochrona przed XXE).
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||||
|
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||||
|
factory.setExpandEntityReferences(false);
|
||||||
|
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||||
|
return builder.parse(new ByteArrayInputStream(xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double number(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Double.parseDouble(value.replace(',', '.'));
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int columnIndex(String column) {
|
||||||
|
int index = 0;
|
||||||
|
for (char letter : column.toCharArray()) {
|
||||||
|
index = index * 26 + (letter - 'A' + 1);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String columnName(int index) {
|
||||||
|
StringBuilder name = new StringBuilder();
|
||||||
|
while (index > 0) {
|
||||||
|
int remainder = (index - 1) % 26;
|
||||||
|
name.insert(0, (char) ('A' + remainder));
|
||||||
|
index = (index - 1) / 26;
|
||||||
|
}
|
||||||
|
return name.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
|
||||||
|
/** Publiczna historia srednich cen ofertowych za m2 dla miasta lub dzielnicy. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/price-history")
|
||||||
|
public class PriceHistoryController {
|
||||||
|
|
||||||
|
private final PriceSnapshotRepository snapshotRepository;
|
||||||
|
private final PriceSnapshotService snapshotService;
|
||||||
|
private final ExternalQuarterPriceRepository externalRepository;
|
||||||
|
|
||||||
|
public PriceHistoryController(PriceSnapshotRepository snapshotRepository, PriceSnapshotService snapshotService,
|
||||||
|
ExternalQuarterPriceRepository externalRepository) {
|
||||||
|
this.snapshotRepository = snapshotRepository;
|
||||||
|
this.snapshotService = snapshotService;
|
||||||
|
this.externalRepository = externalRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PricePoint(LocalDate date, long avgPricePerM2, int listingsCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record QuarterPoint(int year, int quarter, long pricePerM2) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* points - dzienne zapisy od "from"; current - srednia z ogloszen w tej chwili (null, gdy brak);
|
||||||
|
* trackedSince - od kiedy serwis w ogole zbiera historie (null, gdy jeszcze nic nie zapisano);
|
||||||
|
* nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta (puste, gdy zrodlo go nie obejmuje);
|
||||||
|
* nbpMarket - rynek, ktorego dotyczy nbp (NBP nie podaje obu rynkow razem - wtedy wtorny).
|
||||||
|
*/
|
||||||
|
public record PriceHistoryResponse(List<PricePoint> points, PricePoint current, LocalDate trackedSince,
|
||||||
|
List<QuarterPoint> nbp, List<QuarterPoint> gus, String nbpMarket) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public PriceHistoryResponse history(
|
||||||
|
@RequestParam String city,
|
||||||
|
@RequestParam(required = false, defaultValue = "") String district,
|
||||||
|
@RequestParam(required = false, defaultValue = "SALE") OfferType offerType,
|
||||||
|
@RequestParam(required = false, defaultValue = "APARTMENT") PropertyType propertyType,
|
||||||
|
@RequestParam(required = false, defaultValue = "") String market,
|
||||||
|
@RequestParam(required = false) LocalDate from
|
||||||
|
) {
|
||||||
|
LocalDate since = from != null ? from : PriceSnapshotService.today().minusYears(1);
|
||||||
|
String wantedMarket = market.trim();
|
||||||
|
List<PricePoint> points = snapshotRepository
|
||||||
|
.findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
|
||||||
|
PriceSnapshotService.key(city), PriceSnapshotService.key(district), offerType, propertyType,
|
||||||
|
wantedMarket, since)
|
||||||
|
.stream()
|
||||||
|
.map(snapshot -> new PricePoint(snapshot.getSnapshotDate(), snapshot.getAvgPricePerM2(), snapshot.getListingsCount()))
|
||||||
|
.toList();
|
||||||
|
PriceSnapshotService.Average now = snapshotService.currentAverage(offerType, propertyType, wantedMarket, city, district);
|
||||||
|
PricePoint current = now == null ? null
|
||||||
|
: new PricePoint(PriceSnapshotService.today(), now.avgPricePerM2(), now.listingsCount());
|
||||||
|
// NBP i GUS podaja ceny transakcyjne mieszkan - dla domow i najmu nie ma porownania.
|
||||||
|
boolean comparable = offerType == OfferType.SALE && propertyType == PropertyType.APARTMENT;
|
||||||
|
String nbpMarket = wantedMarket.isEmpty() ? "Wtórny" : wantedMarket;
|
||||||
|
String cityKey = PriceSnapshotService.key(city);
|
||||||
|
List<QuarterPoint> nbp = comparable ? quarters(cityKey, nbpMarket, ExternalQuarterPrice.Source.NBP) : List.of();
|
||||||
|
List<QuarterPoint> gus = comparable ? quarters(cityKey, wantedMarket, ExternalQuarterPrice.Source.GUS) : List.of();
|
||||||
|
return new PriceHistoryResponse(points, current, snapshotRepository.findFirstSnapshotDate().orElse(null),
|
||||||
|
nbp, gus, nbpMarket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<QuarterPoint> quarters(String cityKey, String market, ExternalQuarterPrice.Source source) {
|
||||||
|
return externalRepository.findByCityKeyAndMarketOrderByYearAscQuarterAsc(cityKey, market).stream()
|
||||||
|
.filter(price -> price.getSource() == source)
|
||||||
|
.map(price -> new QuarterPoint(price.getYear(), price.getQuarter(), price.getPricePerM2()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
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.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dzienny zapis sredniej ceny ofertowej za m2 z opublikowanych ogloszen - zrodlo historii cen.
|
||||||
|
* Jeden wiersz na (dzien, typ oferty, typ nieruchomosci, rynek, miasto, dzielnica).
|
||||||
|
* Pusta dzielnica = cale miasto, pusty rynek = wtorny i pierwotny razem.
|
||||||
|
* Klucze (cityKey, districtKey) sa znormalizowane, zeby "Kraków" i " kraków" trafialy w to samo.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "price_snapshots",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {
|
||||||
|
"snapshotDate", "offerType", "propertyType", "market", "cityKey", "districtKey"}),
|
||||||
|
indexes = @Index(columnList = "cityKey, districtKey, offerType, propertyType, market, snapshotDate"))
|
||||||
|
public class PriceSnapshot {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDate snapshotDate;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private OfferType offerType;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private PropertyType propertyType;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private String market;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String cityKey;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String districtKey;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 120)
|
||||||
|
private String district;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int listingsCount;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private long avgPricePerM2;
|
||||||
|
|
||||||
|
protected PriceSnapshot() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public PriceSnapshot(LocalDate snapshotDate, OfferType offerType, PropertyType propertyType, String market,
|
||||||
|
String city, String district, int listingsCount, long avgPricePerM2) {
|
||||||
|
this.snapshotDate = snapshotDate;
|
||||||
|
this.offerType = offerType;
|
||||||
|
this.propertyType = propertyType;
|
||||||
|
this.market = market;
|
||||||
|
this.city = city;
|
||||||
|
this.district = district;
|
||||||
|
this.cityKey = PriceSnapshotService.key(city);
|
||||||
|
this.districtKey = PriceSnapshotService.key(district);
|
||||||
|
this.listingsCount = listingsCount;
|
||||||
|
this.avgPricePerM2 = avgPricePerM2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getSnapshotDate() {
|
||||||
|
return snapshotDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getListingsCount() {
|
||||||
|
return listingsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getAvgPricePerM2() {
|
||||||
|
return avgPricePerM2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
|
||||||
|
public interface PriceSnapshotRepository extends JpaRepository<PriceSnapshot, Long> {
|
||||||
|
|
||||||
|
boolean existsBySnapshotDate(LocalDate snapshotDate);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("delete from PriceSnapshot s where s.snapshotDate = :date")
|
||||||
|
void deleteBySnapshotDate(@Param("date") LocalDate date);
|
||||||
|
|
||||||
|
List<PriceSnapshot> findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
|
||||||
|
String cityKey, String districtKey, OfferType offerType, PropertyType propertyType, String market, LocalDate from);
|
||||||
|
|
||||||
|
@Query("select min(s.snapshotDate) from PriceSnapshot s")
|
||||||
|
Optional<LocalDate> findFirstSnapshotDate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package pl.polskalokalnie.pricehistory;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historia cen: codzienny zapis srednich cen ofertowych za m2 z opublikowanych ogloszen.
|
||||||
|
* Danych sprzed pierwszego zapisu nie odtwarzamy - historia rosnie z kazdym dniem.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PriceSnapshotService {
|
||||||
|
|
||||||
|
public static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||||
|
/** Wartosc rynku oznaczajaca "wtorny i pierwotny razem". */
|
||||||
|
public static final String ALL_MARKETS = "";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PriceSnapshotService.class);
|
||||||
|
|
||||||
|
private final ListingRepository listingRepository;
|
||||||
|
private final PriceSnapshotRepository snapshotRepository;
|
||||||
|
|
||||||
|
public PriceSnapshotService(ListingRepository listingRepository, PriceSnapshotRepository snapshotRepository) {
|
||||||
|
this.listingRepository = listingRepository;
|
||||||
|
this.snapshotRepository = snapshotRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Srednia na dany moment - ta sama grupa, co w zapisie dziennym. */
|
||||||
|
public record Average(long avgPricePerM2, int listingsCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record GroupKey(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Co noc zapisujemy stan z konca dnia; przy starcie uzupelniamy dzisiejszy zapis, jesli go brakuje.
|
||||||
|
// Transakcja na tych metodach, bo wywolanie recordToday() z wnetrza klasy omija proxy Springa.
|
||||||
|
@Scheduled(cron = "0 30 2 * * *", zone = "Europe/Warsaw")
|
||||||
|
@Transactional
|
||||||
|
public void recordDaily() {
|
||||||
|
recordToday();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
@Transactional
|
||||||
|
public void recordOnStartupIfMissing() {
|
||||||
|
if (!snapshotRepository.existsBySnapshotDate(today())) {
|
||||||
|
recordToday();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zapis dzisiejszych srednich; ponowne wywolanie w tym samym dniu nadpisuje dzisiejsze wiersze. */
|
||||||
|
@Transactional
|
||||||
|
public int recordToday() {
|
||||||
|
LocalDate date = today();
|
||||||
|
snapshotRepository.deleteBySnapshotDate(date);
|
||||||
|
Map<GroupKey, List<Double>> groups = group(pricedListings());
|
||||||
|
List<PriceSnapshot> rows = new ArrayList<>();
|
||||||
|
groups.forEach((key, prices) -> rows.add(new PriceSnapshot(date, key.offerType(), key.propertyType(), key.market(),
|
||||||
|
key.city(), key.district(), prices.size(), Math.round(average(prices)))));
|
||||||
|
snapshotRepository.saveAll(rows);
|
||||||
|
log.info("Historia cen: zapisano {} srednich za {}", rows.size(), date);
|
||||||
|
return rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktualna srednia liczona wprost z ogloszen - "dzisiaj" na wykresie jest zawsze swieze. */
|
||||||
|
public Average currentAverage(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
|
||||||
|
String wantedCity = key(city);
|
||||||
|
String wantedDistrict = key(district);
|
||||||
|
List<Double> prices = pricedListings().stream()
|
||||||
|
.filter(listing -> listing.getOfferType() == offerType && listing.getPropertyType() == propertyType)
|
||||||
|
.filter(listing -> key(listing.getCity()).equals(wantedCity))
|
||||||
|
.filter(listing -> wantedDistrict.isEmpty() || key(listing.getDistrict()).equals(wantedDistrict))
|
||||||
|
.filter(listing -> market.isEmpty() || market.equalsIgnoreCase(trim(listing.getMarket())))
|
||||||
|
.map(PriceSnapshotService::pricePerM2)
|
||||||
|
.toList();
|
||||||
|
return prices.isEmpty() ? null : new Average(Math.round(average(prices)), prices.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PropertyListing> pricedListings() {
|
||||||
|
return listingRepository.findAll().stream()
|
||||||
|
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||||
|
.filter(listing -> listing.getOfferType() != null && listing.getPropertyType() != null)
|
||||||
|
.filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0)
|
||||||
|
.filter(listing -> listing.getArea() != null && listing.getArea() > 0)
|
||||||
|
.filter(listing -> !trim(listing.getCity()).isEmpty())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kazde ogloszenie liczy sie do miasta i (jesli ma) do dzielnicy - dla wszystkich rynkow i dla swojego rynku.
|
||||||
|
private static Map<GroupKey, List<Double>> group(List<PropertyListing> listings) {
|
||||||
|
Map<GroupKey, List<Double>> groups = new LinkedHashMap<>();
|
||||||
|
for (PropertyListing listing : listings) {
|
||||||
|
double price = pricePerM2(listing);
|
||||||
|
String city = trim(listing.getCity());
|
||||||
|
String district = trim(listing.getDistrict());
|
||||||
|
String market = trim(listing.getMarket());
|
||||||
|
List<String> markets = market.isEmpty() ? List.of(ALL_MARKETS) : List.of(ALL_MARKETS, market);
|
||||||
|
List<String> districts = district.isEmpty() ? List.of("") : List.of("", district);
|
||||||
|
for (String groupMarket : markets) {
|
||||||
|
for (String groupDistrict : districts) {
|
||||||
|
GroupKey key = new GroupKey(listing.getOfferType(), listing.getPropertyType(), groupMarket,
|
||||||
|
city, groupDistrict);
|
||||||
|
groups.computeIfAbsent(canonical(groups, key), ignored -> new ArrayList<>()).add(price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ta sama grupa niezaleznie od wielkosci liter i spacji - pierwsza napotkana pisownia zostaje nazwa.
|
||||||
|
private static GroupKey canonical(Map<GroupKey, List<Double>> groups, GroupKey candidate) {
|
||||||
|
for (GroupKey existing : groups.keySet()) {
|
||||||
|
if (existing.offerType() == candidate.offerType() && existing.propertyType() == candidate.propertyType()
|
||||||
|
&& existing.market().equalsIgnoreCase(candidate.market())
|
||||||
|
&& key(existing.city()).equals(key(candidate.city()))
|
||||||
|
&& key(existing.district()).equals(key(candidate.district()))) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LocalDate today() {
|
||||||
|
return LocalDate.now(ZONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Klucz porownania nazw: male litery, bez nadmiarowych spacji (polskie znaki zostaja). */
|
||||||
|
public static String key(String value) {
|
||||||
|
return Normalizer.normalize(trim(value), Normalizer.Form.NFC).toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double pricePerM2(PropertyListing listing) {
|
||||||
|
return listing.getPrice().doubleValue() / listing.getArea();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double average(List<Double> values) {
|
||||||
|
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,35 @@
|
|||||||
package pl.polskalokalnie.promotion;
|
package pl.polskalokalnie.promotion;
|
||||||
|
|
||||||
import java.util.Map;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import pl.polskalokalnie.payment.AutoPayService;
|
|
||||||
import pl.polskalokalnie.payment.PaymentConfig;
|
|
||||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
|
||||||
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
|
|
||||||
*
|
*
|
||||||
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
|
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
|
||||||
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
|
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
|
||||||
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
|
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
|
||||||
|
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/payments/autopay")
|
@RequestMapping("/api/payments/autopay")
|
||||||
public class AutoPayItnController {
|
public class AutoPayItnController {
|
||||||
|
|
||||||
private final AutoPayService autoPayService;
|
private final AutoPayItnService itnService;
|
||||||
private final PaymentConfigService paymentConfigService;
|
|
||||||
private final PromotionService promotionService;
|
|
||||||
|
|
||||||
public AutoPayItnController(AutoPayService autoPayService,
|
public AutoPayItnController(AutoPayItnService itnService) {
|
||||||
PaymentConfigService paymentConfigService,
|
this.itnService = itnService;
|
||||||
PromotionService promotionService) {
|
|
||||||
this.autoPayService = autoPayService;
|
|
||||||
this.paymentConfigService = paymentConfigService;
|
|
||||||
this.promotionService = promotionService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/itn")
|
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
|
||||||
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
|
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
|
||||||
PaymentConfig cfg = paymentConfigService.get();
|
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
|
||||||
if (!autoPayService.verifyItn(params, cfg)) {
|
return ResponseEntity.ok()
|
||||||
return ResponseEntity.badRequest().body("ERROR: invalid hash");
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
}
|
.body(itnService.handle(transactions));
|
||||||
String orderId = params.get("OrderID");
|
|
||||||
if (orderId != null && isSuccess(params)) {
|
|
||||||
promotionService.markPaidByExternalId(orderId);
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok("OK");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isSuccess(Map<String, String> params) {
|
|
||||||
String status = params.getOrDefault("PaymentStatus",
|
|
||||||
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
|
|
||||||
String s = status == null ? "" : status.trim().toUpperCase();
|
|
||||||
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package pl.polskalokalnie.promotion;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.payment.AutoPayItnParser;
|
||||||
|
import pl.polskalokalnie.payment.AutoPayService;
|
||||||
|
import pl.polskalokalnie.payment.PaymentConfig;
|
||||||
|
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
|
||||||
|
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
|
||||||
|
*
|
||||||
|
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
|
||||||
|
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
|
||||||
|
* 2. zgodnosc identyfikatora serwisu,
|
||||||
|
* 3. istnienie zamowienia o danym OrderID,
|
||||||
|
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
|
||||||
|
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
|
||||||
|
*
|
||||||
|
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AutoPayItnService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
|
||||||
|
private static final String CONFIRMED = "CONFIRMED";
|
||||||
|
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
|
||||||
|
private static final String NL = "\n";
|
||||||
|
|
||||||
|
private final PaymentConfigService paymentConfigService;
|
||||||
|
private final AutoPayService autoPayService;
|
||||||
|
private final PromotionOrderRepository orderRepository;
|
||||||
|
private final PromotionService promotionService;
|
||||||
|
|
||||||
|
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
|
||||||
|
PromotionOrderRepository orderRepository, PromotionService promotionService) {
|
||||||
|
this.paymentConfigService = paymentConfigService;
|
||||||
|
this.autoPayService = autoPayService;
|
||||||
|
this.orderRepository = orderRepository;
|
||||||
|
this.promotionService = promotionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
|
||||||
|
*
|
||||||
|
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String handle(String transactionsBase64) {
|
||||||
|
PaymentConfig cfg = paymentConfigService.get();
|
||||||
|
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
|
||||||
|
|
||||||
|
boolean authentic = verifySignature(cfg, itn);
|
||||||
|
if (!authentic) {
|
||||||
|
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
|
||||||
|
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Confirmation> confirmations = new ArrayList<>();
|
||||||
|
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
|
||||||
|
String result = authentic ? process(tx) : NOT_CONFIRMED;
|
||||||
|
confirmations.add(new Confirmation(tx.orderId(), result));
|
||||||
|
}
|
||||||
|
return response(cfg, confirmations);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
|
||||||
|
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String expected = autoPayService.hash(cfg, itn.signedValues());
|
||||||
|
return AutoPayService.hashMatches(expected, itn.hash());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String process(AutoPayItnParser.ItnTransaction tx) {
|
||||||
|
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
|
||||||
|
return NOT_CONFIRMED;
|
||||||
|
}
|
||||||
|
PromotionOrder order = found.get();
|
||||||
|
|
||||||
|
if (!tx.success()) {
|
||||||
|
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
|
||||||
|
// ale niczego nie naliczamy.
|
||||||
|
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
|
||||||
|
order.setStatus(OrderStatus.FAILED);
|
||||||
|
orderRepository.save(order);
|
||||||
|
}
|
||||||
|
return CONFIRMED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
|
||||||
|
// ale nie nalicza drugi raz kredytow ani wyroznienia.
|
||||||
|
if (order.getStatus() == OrderStatus.PAID) {
|
||||||
|
return CONFIRMED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
|
||||||
|
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
|
||||||
|
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
|
||||||
|
return NOT_CONFIRMED;
|
||||||
|
}
|
||||||
|
|
||||||
|
promotionService.applyPaid(order);
|
||||||
|
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
|
||||||
|
return CONFIRMED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
|
||||||
|
static boolean amountMatches(BigDecimal expected, String actual) {
|
||||||
|
if (expected == null || actual == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return expected.compareTo(new BigDecimal(actual)) == 0;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Confirmation(String orderId, String result) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
|
||||||
|
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
|
||||||
|
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
|
||||||
|
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
values.add(serviceId);
|
||||||
|
for (Confirmation c : confirmations) {
|
||||||
|
values.add(c.orderId());
|
||||||
|
values.add(c.result());
|
||||||
|
}
|
||||||
|
String hash = autoPayService.hash(cfg, values);
|
||||||
|
|
||||||
|
StringBuilder entries = new StringBuilder();
|
||||||
|
for (Confirmation c : confirmations) {
|
||||||
|
entries.append(" <transactionConfirmed>").append(NL)
|
||||||
|
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
|
||||||
|
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
|
||||||
|
.append(" </transactionConfirmed>").append(NL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
|
||||||
|
+ "<confirmationList>" + NL
|
||||||
|
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
|
||||||
|
+ " <transactionsConfirmations>" + NL
|
||||||
|
+ entries
|
||||||
|
+ " </transactionsConfirmations>" + NL
|
||||||
|
+ " <hash>" + hash + "</hash>" + NL
|
||||||
|
+ "</confirmationList>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decode(String base64) {
|
||||||
|
try {
|
||||||
|
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escape(String value) {
|
||||||
|
return value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||||
|
|
||||||
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
|
||||||
@RestController
|
@RestController
|
||||||
@@ -51,6 +52,11 @@ public class PromotionController {
|
|||||||
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/order/{id}/autopay-form")
|
||||||
|
public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) {
|
||||||
|
return promotionService.autoPayForm(authentication.getName(), id);
|
||||||
|
}
|
||||||
|
|
||||||
public record CreditsResponse(int credits) {
|
public record CreditsResponse(int credits) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
package pl.polskalokalnie.promotion;
|
package pl.polskalokalnie.promotion;
|
||||||
|
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
|
||||||
Optional<PromotionOrder> findByExternalId(String externalId);
|
Optional<PromotionOrder> findByExternalId(String externalId);
|
||||||
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
List<PromotionOrder> findByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zamowienie z blokada wiersza do konca transakcji - rownolegle notyfikacje ITN o tym samym
|
||||||
|
* zamowieniu obsluguja sie po kolei, wiec kredyty nie zostana naliczone dwa razy.
|
||||||
|
*/
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select o from PromotionOrder o where o.externalId = :externalId")
|
||||||
|
Optional<PromotionOrder> findForUpdateByExternalId(@Param("externalId") String externalId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import pl.polskalokalnie.listing.ListingRepository;
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.payment.AutoPayService;
|
import pl.polskalokalnie.payment.AutoPayService;
|
||||||
|
import pl.polskalokalnie.payment.AutoPayStartForm;
|
||||||
import pl.polskalokalnie.payment.PaymentConfig;
|
import pl.polskalokalnie.payment.PaymentConfig;
|
||||||
import pl.polskalokalnie.payment.PaymentConfigService;
|
import pl.polskalokalnie.payment.PaymentConfigService;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
@@ -27,7 +28,7 @@ public class PromotionService {
|
|||||||
private final ListingRepository listingRepository;
|
private final ListingRepository listingRepository;
|
||||||
private final PaymentConfigService paymentConfigService;
|
private final PaymentConfigService paymentConfigService;
|
||||||
private final AutoPayService autoPayService;
|
private final AutoPayService autoPayService;
|
||||||
private final String baseUrl;
|
private final boolean autoConfirmPayments;
|
||||||
|
|
||||||
public PromotionService(PromotionPlanRepository planRepository,
|
public PromotionService(PromotionPlanRepository planRepository,
|
||||||
PromotionPackageRepository packageRepository,
|
PromotionPackageRepository packageRepository,
|
||||||
@@ -36,7 +37,7 @@ public class PromotionService {
|
|||||||
ListingRepository listingRepository,
|
ListingRepository listingRepository,
|
||||||
PaymentConfigService paymentConfigService,
|
PaymentConfigService paymentConfigService,
|
||||||
AutoPayService autoPayService,
|
AutoPayService autoPayService,
|
||||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
|
||||||
this.planRepository = planRepository;
|
this.planRepository = planRepository;
|
||||||
this.packageRepository = packageRepository;
|
this.packageRepository = packageRepository;
|
||||||
this.orderRepository = orderRepository;
|
this.orderRepository = orderRepository;
|
||||||
@@ -44,7 +45,7 @@ public class PromotionService {
|
|||||||
this.listingRepository = listingRepository;
|
this.listingRepository = listingRepository;
|
||||||
this.paymentConfigService = paymentConfigService;
|
this.paymentConfigService = paymentConfigService;
|
||||||
this.autoPayService = autoPayService;
|
this.autoPayService = autoPayService;
|
||||||
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
|
this.autoConfirmPayments = autoConfirmPayments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Publiczne (uzytkownik) ---
|
// --- Publiczne (uzytkownik) ---
|
||||||
@@ -63,6 +64,14 @@ public class PromotionService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public CheckoutResponse checkout(String email, CheckoutRequest request) {
|
public CheckoutResponse checkout(String email, CheckoutRequest request) {
|
||||||
|
PaymentConfig cfg = paymentConfigService.get();
|
||||||
|
// Bez skonfigurowanej bramki nie ma komu zaplacic - zamowienie uznane "od reki" oznaczaloby
|
||||||
|
// darmowe promowanie dla kazdego. Symulacja platnosci tylko przy jawnie wlaczonym APP_PAYMENTS_AUTO_CONFIRM (lokalnie).
|
||||||
|
if (!cfg.isLive() && !autoConfirmPayments) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
|
||||||
|
}
|
||||||
|
|
||||||
PromotionOrder order = new PromotionOrder();
|
PromotionOrder order = new PromotionOrder();
|
||||||
order.setUserEmail(email);
|
order.setUserEmail(email);
|
||||||
order.setKind(request.kind());
|
order.setKind(request.kind());
|
||||||
@@ -70,8 +79,7 @@ public class PromotionService {
|
|||||||
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
|
||||||
|
|
||||||
if (request.kind() == PromotionKind.PLAN) {
|
if (request.kind() == PromotionKind.PLAN) {
|
||||||
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
|
PromotionPlan plan = requireActivePlan(request.planId());
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
|
||||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||||
order.setRefId(plan.getId());
|
order.setRefId(plan.getId());
|
||||||
order.setItemName("Promowanie: " + plan.getName());
|
order.setItemName("Promowanie: " + plan.getName());
|
||||||
@@ -79,6 +87,7 @@ public class PromotionService {
|
|||||||
order.setListingId(listing.getId());
|
order.setListingId(listing.getId());
|
||||||
} else {
|
} else {
|
||||||
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
|
||||||
|
.filter(PromotionPackage::isActive)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||||
order.setRefId(pack.getId());
|
order.setRefId(pack.getId());
|
||||||
order.setItemName("Pakiet promowan: " + pack.getName());
|
order.setItemName("Pakiet promowan: " + pack.getName());
|
||||||
@@ -86,16 +95,43 @@ public class PromotionService {
|
|||||||
}
|
}
|
||||||
order = orderRepository.save(order);
|
order = orderRepository.save(order);
|
||||||
|
|
||||||
PaymentConfig cfg = paymentConfigService.get();
|
|
||||||
if (!cfg.isLive()) {
|
if (!cfg.isLive()) {
|
||||||
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
|
// Lokalny tryb bez bramki (app.payments.auto-confirm) - platnosc uznajemy od razu.
|
||||||
applyPaid(order);
|
applyPaid(order);
|
||||||
return CheckoutResponse.paid(order.getId());
|
return CheckoutResponse.paid(order.getId());
|
||||||
}
|
}
|
||||||
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
|
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
|
||||||
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
|
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
|
||||||
order.getItemName(), email, returnUrl);
|
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
|
||||||
return CheckoutResponse.redirect(order.getId(), url);
|
}
|
||||||
|
|
||||||
|
/** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */
|
||||||
|
public AutoPayStartForm autoPayForm(String email, Long orderId) {
|
||||||
|
PromotionOrder order = orderRepository.findById(orderId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
|
||||||
|
if (!order.getUserEmail().equalsIgnoreCase(email)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
|
||||||
|
}
|
||||||
|
if (order.getStatus() != OrderStatus.PENDING) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność");
|
||||||
|
}
|
||||||
|
PaymentConfig cfg = paymentConfigService.get();
|
||||||
|
if (!cfg.isLive()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
|
||||||
|
}
|
||||||
|
return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(),
|
||||||
|
paymentDescription(order), order.getUserEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki
|
||||||
|
* alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu.
|
||||||
|
*/
|
||||||
|
static String paymentDescription(PromotionOrder order) {
|
||||||
|
return order.getKind() == PromotionKind.PLAN
|
||||||
|
? "Promowanie ogloszenia nr " + order.getListingId()
|
||||||
|
: "Pakiet promowan, zamowienie nr " + order.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -115,33 +151,24 @@ public class PromotionService {
|
|||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||||
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
|
userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
|
||||||
userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
order.setStatus(OrderStatus.PAID);
|
order.setStatus(OrderStatus.PAID);
|
||||||
order.setPaidAt(Instant.now());
|
order.setPaidAt(Instant.now());
|
||||||
orderRepository.save(order);
|
orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
|
|
||||||
@Transactional
|
|
||||||
public void markPaidByExternalId(String externalId) {
|
|
||||||
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
public int promoteWithCredit(String email, CreditPromoteRequest request) {
|
||||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
|
||||||
if (user.getPromotionCredits() <= 0) {
|
PromotionPlan plan = requireActivePlan(request.planId());
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
|
||||||
}
|
|
||||||
PromotionPlan plan = planRepository.findById(request.planId())
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
|
||||||
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
PropertyListing listing = requireOwnedListing(request.listingId(), email);
|
||||||
|
|
||||||
user.setPromotionCredits(user.getPromotionCredits() - 1);
|
// Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
|
||||||
userRepository.save(user);
|
if (userRepository.consumePromotionCredit(user.getId()) == 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
|
||||||
|
}
|
||||||
extendPromotion(listing, plan.getDurationDays());
|
extendPromotion(listing, plan.getDurationDays());
|
||||||
listingRepository.save(listing);
|
listingRepository.save(listing);
|
||||||
|
|
||||||
@@ -158,7 +185,15 @@ public class PromotionService {
|
|||||||
order.setPaidAt(Instant.now());
|
order.setPaidAt(Instant.now());
|
||||||
orderRepository.save(order);
|
orderRepository.save(order);
|
||||||
|
|
||||||
return user.getPromotionCredits();
|
return credits(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kupic (i wydac na nie kredyt) mozna tylko plan wystawiony w ofercie - wylaczony przez
|
||||||
|
// administracje plan (np. o starej cenie) jest niedostepny takze przy wywolaniu API wprost.
|
||||||
|
private PromotionPlan requireActivePlan(Long planId) {
|
||||||
|
return planRepository.findById(requireId(planId, "planId"))
|
||||||
|
.filter(PromotionPlan::isActive)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public String orderStatus(String email, Long id) {
|
public String orderStatus(String email, Long id) {
|
||||||
@@ -284,7 +319,8 @@ public class PromotionService {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
|
||||||
private static String generateExternalId() {
|
private static String generateExternalId() {
|
||||||
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
|
return java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,20 +19,24 @@ public class ListingReportController {
|
|||||||
this.userReportService = userReportService;
|
this.userReportService = userReportService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
|
||||||
|
// zgloszonego) widzi tylko administracja.
|
||||||
@PostMapping("/listings")
|
@PostMapping("/listings")
|
||||||
public ListingReportResponse createListingReport(
|
public ReportReceiptResponse createListingReport(
|
||||||
@Valid @RequestBody CreateListingReportRequest request,
|
@Valid @RequestBody CreateListingReportRequest request,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
return listingReportService.create(request, authentication.getName());
|
ListingReportResponse report = listingReportService.create(request, authentication.getName());
|
||||||
|
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
|
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
public UserReportResponse createUserReport(
|
public ReportReceiptResponse createUserReport(
|
||||||
@Valid @RequestBody CreateUserReportRequest request,
|
@Valid @RequestBody CreateUserReportRequest request,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
return userReportService.create(request, authentication.getName());
|
UserReportResponse report = userReportService.create(request, authentication.getName());
|
||||||
|
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import pl.polskalokalnie.listing.ListingRepository;
|
import pl.polskalokalnie.listing.ListingRepository;
|
||||||
|
import pl.polskalokalnie.listing.ListingStatus;
|
||||||
import pl.polskalokalnie.listing.PropertyListing;
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
@@ -30,7 +31,10 @@ public class ListingReportService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
||||||
|
// Zglaszac mozna tylko opublikowane ogloszenia - inaczej kolejne numery pozwalaly sprawdzac
|
||||||
|
// istnienie ogloszen oczekujacych i odrzuconych.
|
||||||
PropertyListing listing = listingRepository.findById(request.listingId())
|
PropertyListing listing = listingRepository.findById(request.listingId())
|
||||||
|
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||||
|
|
||||||
ListingReport report = new ListingReport();
|
ListingReport report = new ListingReport();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package pl.polskalokalnie.report;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Potwierdzenie przyjecia zgloszenia dla zglaszajacego. Celowo bez danych zgloszonego (e-mail,
|
||||||
|
* nazwa, czy ma konto) i ogloszenia - pelne zgloszenie widzi tylko administracja.
|
||||||
|
*/
|
||||||
|
public record ReportReceiptResponse(Long id, Instant createdAt, String reasonTitle, ListingReportStatus status) {
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import pl.polskalokalnie.listing.ListingService;
|
||||||
|
import pl.polskalokalnie.message.Conversation;
|
||||||
|
import pl.polskalokalnie.message.ConversationRepository;
|
||||||
|
import pl.polskalokalnie.message.Message;
|
||||||
|
import pl.polskalokalnie.message.MessageRepository;
|
||||||
import pl.polskalokalnie.user.AppUser;
|
import pl.polskalokalnie.user.AppUser;
|
||||||
import pl.polskalokalnie.user.Role;
|
import pl.polskalokalnie.user.Role;
|
||||||
import pl.polskalokalnie.user.UserRepository;
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
@@ -16,14 +21,21 @@ public class UserReportService {
|
|||||||
|
|
||||||
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
|
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
|
||||||
private static final int MAX_CONVERSATION_MESSAGES = 50;
|
private static final int MAX_CONVERSATION_MESSAGES = 50;
|
||||||
private static final int MAX_MESSAGE_LENGTH = 2000;
|
|
||||||
|
|
||||||
private final UserReportRepository userReportRepository;
|
private final UserReportRepository userReportRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final MessageRepository messageRepository;
|
||||||
|
private final ListingService listingService;
|
||||||
|
|
||||||
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository) {
|
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
|
||||||
|
ConversationRepository conversationRepository, MessageRepository messageRepository,
|
||||||
|
ListingService listingService) {
|
||||||
this.userReportRepository = userReportRepository;
|
this.userReportRepository = userReportRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
this.listingService = listingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -33,7 +45,6 @@ public class UserReportService {
|
|||||||
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
|
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
|
||||||
report.setListingId(request.listingId());
|
report.setListingId(request.listingId());
|
||||||
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
|
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
|
||||||
report.setConversation(cleanConversation(request.conversation()));
|
|
||||||
report.setReasonId(request.reasonId().trim());
|
report.setReasonId(request.reasonId().trim());
|
||||||
report.setReasonTitle(request.reasonTitle().trim());
|
report.setReasonTitle(request.reasonTitle().trim());
|
||||||
report.setDetails(ReportAttachments.trimToNull(request.details()));
|
report.setDetails(ReportAttachments.trimToNull(request.details()));
|
||||||
@@ -42,9 +53,21 @@ public class UserReportService {
|
|||||||
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
|
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
|
||||||
report.setStatus(ListingReportStatus.OPEN);
|
report.setStatus(ListingReportStatus.OPEN);
|
||||||
|
|
||||||
// Konto zgloszonego wiazemy tylko wtedy, gdy naprawde istnieje - rozmowy demonstracyjne
|
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
|
||||||
// nie maja odpowiednika w bazie i zostaja opisane sama nazwa z watku.
|
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
|
||||||
AppUser reported = findReported(request.reportedEmail());
|
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje, a tresci rozmowy
|
||||||
|
// przyslanej przez przegladarke nie zapisujemy - administracja wzielaby ja za dowod.
|
||||||
|
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
|
||||||
|
Conversation conversation = reporterConversation(request.conversationId(), reporter);
|
||||||
|
AppUser reported;
|
||||||
|
if (conversation != null) {
|
||||||
|
reported = userRepository.findById(conversation.otherParticipant(reporter.getId())).orElse(null);
|
||||||
|
report.setListingId(conversation.getListingId());
|
||||||
|
report.setListingTitle(conversation.getListingTitle());
|
||||||
|
report.setConversation(conversationFromDatabase(conversation, reporter.getId()));
|
||||||
|
} else {
|
||||||
|
reported = findReported(request.reportedEmail());
|
||||||
|
}
|
||||||
if (reported != null) {
|
if (reported != null) {
|
||||||
report.setReportedEmail(reported.getEmail());
|
report.setReportedEmail(reported.getEmail());
|
||||||
report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName());
|
report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName());
|
||||||
@@ -83,6 +106,7 @@ public class UserReportService {
|
|||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listingService.deleteAllOfOwner(reported.getEmail());
|
||||||
userRepository.delete(reported);
|
userRepository.delete(reported);
|
||||||
|
|
||||||
report.setReportedUserDeleted(true);
|
report.setReportedUserDeleted(true);
|
||||||
@@ -95,32 +119,31 @@ public class UserReportService {
|
|||||||
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
|
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
|
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
|
||||||
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
|
private Conversation reporterConversation(String conversationId, AppUser reporter) {
|
||||||
if (payloads == null || payloads.isEmpty()) {
|
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
|
||||||
return List.of();
|
return null;
|
||||||
}
|
}
|
||||||
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
|
return conversationRepository.findById(Long.valueOf(conversationId.trim()))
|
||||||
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
|
.filter(conversation -> conversation.isParticipant(reporter.getId()))
|
||||||
: payloads;
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
List<UserReportMessage> cleaned = new ArrayList<>();
|
private List<UserReportMessage> conversationFromDatabase(Conversation conversation, Long reporterId) {
|
||||||
for (UserReportMessagePayload payload : tail) {
|
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId());
|
||||||
if (payload == null) {
|
List<Message> tail = messages.size() > MAX_CONVERSATION_MESSAGES
|
||||||
continue;
|
? messages.subList(messages.size() - MAX_CONVERSATION_MESSAGES, messages.size())
|
||||||
}
|
: messages;
|
||||||
String content = payload.content() == null ? "" : payload.content().trim();
|
List<UserReportMessage> snapshot = new ArrayList<>();
|
||||||
if (content.length() > MAX_MESSAGE_LENGTH) {
|
for (Message source : tail) {
|
||||||
content = content.substring(0, MAX_MESSAGE_LENGTH);
|
|
||||||
}
|
|
||||||
UserReportMessage message = new UserReportMessage();
|
UserReportMessage message = new UserReportMessage();
|
||||||
message.setFromReporter(payload.fromReporter());
|
message.setFromReporter(source.getSenderId().equals(reporterId));
|
||||||
message.setContent(content);
|
message.setContent(source.getContent());
|
||||||
message.setSentAt(payload.sentAt());
|
message.setSentAt(source.getCreatedAt());
|
||||||
message.setHasPhoto(payload.hasPhoto());
|
message.setHasPhoto(false);
|
||||||
cleaned.add(message);
|
snapshot.add(message);
|
||||||
}
|
}
|
||||||
return cleaned;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppUser findReported(String email) {
|
private AppUser findReported(String email) {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package pl.polskalokalnie.search;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisane wyszukiwanie uzytkownika ("Zapisz wyszukiwanie" na liscie ofert). Wczesniej
|
||||||
|
* zylo tylko w localStorage przegladarki, wiec ginelo po zmianie urzadzenia albo wyczyszczeniu
|
||||||
|
* danych. Nie wysylamy z niego zadnych powiadomien - to lista do wygodnego wracania do filtrow.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "saved_searches")
|
||||||
|
public class SavedSearch {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_email", nullable = false, length = 180)
|
||||||
|
private String userEmail;
|
||||||
|
|
||||||
|
/** "buy" albo "rent" - te same wartosci co w adresach /kupno i /wynajem. */
|
||||||
|
@Column(nullable = false, length = 10)
|
||||||
|
private String mode;
|
||||||
|
|
||||||
|
@Column(length = 120)
|
||||||
|
private String city;
|
||||||
|
|
||||||
|
@Column(name = "property_type", length = 60)
|
||||||
|
private String propertyType;
|
||||||
|
|
||||||
|
@Column(name = "price_min")
|
||||||
|
private Long priceMin;
|
||||||
|
|
||||||
|
@Column(name = "price_max")
|
||||||
|
private Long priceMax;
|
||||||
|
|
||||||
|
@Column(name = "area_min")
|
||||||
|
private Integer areaMin;
|
||||||
|
|
||||||
|
@Column(name = "area_max")
|
||||||
|
private Integer areaMax;
|
||||||
|
|
||||||
|
private Integer rooms;
|
||||||
|
|
||||||
|
@Column(name = "secondary_market", nullable = false)
|
||||||
|
private boolean secondaryMarket;
|
||||||
|
|
||||||
|
@Column(name = "primary_market", nullable = false)
|
||||||
|
private boolean primaryMarket;
|
||||||
|
|
||||||
|
@Column(name = "no_fee_only", nullable = false)
|
||||||
|
private boolean noFeeOnly;
|
||||||
|
|
||||||
|
/** Pozostale filtry ("wiecej filtrow") jako JSON - jest ich wiele i zmieniaja sie z widokiem. */
|
||||||
|
@Column(name = "more_filters", columnDefinition = "text")
|
||||||
|
private String moreFilters;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserEmail() {
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserEmail(String userEmail) {
|
||||||
|
this.userEmail = userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMode() {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMode(String mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCity() {
|
||||||
|
return city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCity(String city) {
|
||||||
|
this.city = city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPropertyType() {
|
||||||
|
return propertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPropertyType(String propertyType) {
|
||||||
|
this.propertyType = propertyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPriceMin() {
|
||||||
|
return priceMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPriceMin(Long priceMin) {
|
||||||
|
this.priceMin = priceMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPriceMax() {
|
||||||
|
return priceMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPriceMax(Long priceMax) {
|
||||||
|
this.priceMax = priceMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAreaMin() {
|
||||||
|
return areaMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAreaMin(Integer areaMin) {
|
||||||
|
this.areaMin = areaMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAreaMax() {
|
||||||
|
return areaMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAreaMax(Integer areaMax) {
|
||||||
|
this.areaMax = areaMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRooms() {
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRooms(Integer rooms) {
|
||||||
|
this.rooms = rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSecondaryMarket() {
|
||||||
|
return secondaryMarket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecondaryMarket(boolean secondaryMarket) {
|
||||||
|
this.secondaryMarket = secondaryMarket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPrimaryMarket() {
|
||||||
|
return primaryMarket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrimaryMarket(boolean primaryMarket) {
|
||||||
|
this.primaryMarket = primaryMarket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNoFeeOnly() {
|
||||||
|
return noFeeOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNoFeeOnly(boolean noFeeOnly) {
|
||||||
|
this.noFeeOnly = noFeeOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMoreFilters() {
|
||||||
|
return moreFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMoreFilters(String moreFilters) {
|
||||||
|
this.moreFilters = moreFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(Instant createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(Instant updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package pl.polskalokalnie.search;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisane wyszukiwania zalogowanego uzytkownika. Dostep wymaga logowania (regula
|
||||||
|
* anyRequest().authenticated() w SecurityConfig), a kazda operacja dotyczy wylacznie
|
||||||
|
* wpisow wlasciciela konta. Zadnych powiadomien - to tylko lista zapisanych filtrow.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/saved-searches")
|
||||||
|
public class SavedSearchController {
|
||||||
|
|
||||||
|
/** Tyle samo, ile dotad miescil zapis w przegladarce - chroni przed zasypaniem listy. */
|
||||||
|
static final int LIMIT = 60;
|
||||||
|
|
||||||
|
private final SavedSearchRepository repository;
|
||||||
|
|
||||||
|
public SavedSearchController(SavedSearchRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kryteria wyszukiwania - dokladnie to, co pokazuje pasek filtrow na liscie ofert. */
|
||||||
|
public record SavedSearchRequest(String mode, String city, String propertyType, Long priceMin, Long priceMax,
|
||||||
|
Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny, boolean pierwotny,
|
||||||
|
boolean noFeeOnly, String more) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SavedSearchResponse(Long id, String mode, String city, String propertyType, Long priceMin,
|
||||||
|
Long priceMax, Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny,
|
||||||
|
boolean pierwotny, boolean noFeeOnly, String more, Instant createdAt,
|
||||||
|
Instant updatedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<SavedSearchResponse> list(Authentication authentication) {
|
||||||
|
return repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(authentication.getName()).stream()
|
||||||
|
.map(SavedSearchController::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@Transactional
|
||||||
|
public SavedSearchResponse add(@RequestBody SavedSearchRequest request, Authentication authentication) {
|
||||||
|
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak trybu wyszukiwania");
|
||||||
|
}
|
||||||
|
String userEmail = authentication.getName();
|
||||||
|
List<SavedSearch> existing = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||||
|
SavedSearch duplicate = existing.stream().filter(saved -> sameCriteria(saved, request)).findFirst().orElse(null);
|
||||||
|
if (duplicate != null) {
|
||||||
|
return toResponse(duplicate);
|
||||||
|
}
|
||||||
|
// Najstarsze wpisy ponad limit usuwamy, zeby zapis zawsze sie udal.
|
||||||
|
existing.stream().skip(LIMIT - 1L).forEach(repository::delete);
|
||||||
|
return toResponse(repository.save(fromRequest(request, userEmail)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przeniesienie wyszukiwan zapisanych wczesniej w przegladarce. Dodajemy tylko te,
|
||||||
|
* ktorych uzytkownik nie ma jeszcze na koncie - import mozna powtorzyc bez duplikatow.
|
||||||
|
*/
|
||||||
|
@PostMapping("/import")
|
||||||
|
@Transactional
|
||||||
|
public List<SavedSearchResponse> importLocal(@RequestBody List<SavedSearchRequest> requests, Authentication authentication) {
|
||||||
|
String userEmail = authentication.getName();
|
||||||
|
if (requests != null) {
|
||||||
|
for (SavedSearchRequest request : requests) {
|
||||||
|
if (request == null || request.mode() == null || request.mode().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<SavedSearch> current = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
|
||||||
|
if (current.size() >= LIMIT || current.stream().anyMatch(saved -> sameCriteria(saved, request))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
repository.save(fromRequest(request, userEmail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list(authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> remove(@PathVariable Long id, Authentication authentication) {
|
||||||
|
repository.findById(id)
|
||||||
|
.filter(saved -> saved.getUserEmail().equalsIgnoreCase(authentication.getName()))
|
||||||
|
.ifPresent(repository::delete);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedSearch fromRequest(SavedSearchRequest request, String userEmail) {
|
||||||
|
SavedSearch saved = new SavedSearch();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
saved.setUserEmail(userEmail);
|
||||||
|
saved.setMode(request.mode());
|
||||||
|
saved.setCity(request.city());
|
||||||
|
saved.setPropertyType(request.propertyType());
|
||||||
|
saved.setPriceMin(request.priceMin());
|
||||||
|
saved.setPriceMax(request.priceMax());
|
||||||
|
saved.setAreaMin(request.areaMin());
|
||||||
|
saved.setAreaMax(request.areaMax());
|
||||||
|
saved.setRooms(request.rooms());
|
||||||
|
saved.setSecondaryMarket(request.wtorny());
|
||||||
|
saved.setPrimaryMarket(request.pierwotny());
|
||||||
|
saved.setNoFeeOnly(request.noFeeOnly());
|
||||||
|
saved.setMoreFilters(request.more());
|
||||||
|
saved.setCreatedAt(now);
|
||||||
|
saved.setUpdatedAt(now);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean sameCriteria(SavedSearch saved, SavedSearchRequest request) {
|
||||||
|
return Objects.equals(saved.getMode(), request.mode())
|
||||||
|
&& Objects.equals(trim(saved.getCity()), trim(request.city()))
|
||||||
|
&& Objects.equals(trim(saved.getPropertyType()), trim(request.propertyType()))
|
||||||
|
&& Objects.equals(saved.getPriceMin(), request.priceMin())
|
||||||
|
&& Objects.equals(saved.getPriceMax(), request.priceMax())
|
||||||
|
&& Objects.equals(saved.getAreaMin(), request.areaMin())
|
||||||
|
&& Objects.equals(saved.getAreaMax(), request.areaMax())
|
||||||
|
&& Objects.equals(saved.getRooms(), request.rooms())
|
||||||
|
&& saved.isSecondaryMarket() == request.wtorny()
|
||||||
|
&& saved.isPrimaryMarket() == request.pierwotny()
|
||||||
|
&& saved.isNoFeeOnly() == request.noFeeOnly()
|
||||||
|
&& Objects.equals(trim(saved.getMoreFilters()), trim(request.more()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedSearchResponse toResponse(SavedSearch saved) {
|
||||||
|
return new SavedSearchResponse(saved.getId(), saved.getMode(), saved.getCity(), saved.getPropertyType(),
|
||||||
|
saved.getPriceMin(), saved.getPriceMax(), saved.getAreaMin(), saved.getAreaMax(), saved.getRooms(),
|
||||||
|
saved.isSecondaryMarket(), saved.isPrimaryMarket(), saved.isNoFeeOnly(), saved.getMoreFilters(),
|
||||||
|
saved.getCreatedAt(), saved.getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package pl.polskalokalnie.search;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import pl.polskalokalnie.listing.OfferType;
|
||||||
|
import pl.polskalokalnie.listing.PropertyListing;
|
||||||
|
import pl.polskalokalnie.listing.PropertyType;
|
||||||
|
import pl.polskalokalnie.notification.NotificationService;
|
||||||
|
import pl.polskalokalnie.user.UserRepository;
|
||||||
|
import pl.polskalokalnie.user.UserSettings;
|
||||||
|
import pl.polskalokalnie.user.UserSettingsRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Powiadomienia o nowych ofertach pasujacych do zapisanych wyszukiwan.
|
||||||
|
*
|
||||||
|
* Trafiaja wylacznie na liste powiadomien w serwisie (/powiadomienia) - maili z nich nie
|
||||||
|
* wysylamy. Wywolywane, gdy ogloszenie staje sie widoczne publicznie (moderacja zatwierdza
|
||||||
|
* albo wlasciciel wznawia wstrzymane ogloszenie). Klucz deduplikacji pilnuje, zeby to samo
|
||||||
|
* ogloszenie nie wrocilo do tego samego wyszukiwania po kolejnej zmianie statusu.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SavedSearchNotifier {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SavedSearchNotifier.class);
|
||||||
|
|
||||||
|
private final SavedSearchRepository savedSearchRepository;
|
||||||
|
private final UserSettingsRepository userSettingsRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
|
public SavedSearchNotifier(SavedSearchRepository savedSearchRepository,
|
||||||
|
UserSettingsRepository userSettingsRepository, UserRepository userRepository,
|
||||||
|
NotificationService notificationService) {
|
||||||
|
this.savedSearchRepository = savedSearchRepository;
|
||||||
|
this.userSettingsRepository = userSettingsRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.notificationService = notificationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyMatching(PropertyListing listing) {
|
||||||
|
if (listing == null || listing.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<SavedSearch> searches;
|
||||||
|
try {
|
||||||
|
searches = savedSearchRepository.findAll();
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("Nie udalo sie odczytac zapisanych wyszukiwan: {}", ex.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (SavedSearch search : searches) {
|
||||||
|
String email = search.getUserEmail();
|
||||||
|
if (email == null || email.equalsIgnoreCase(listing.getOwnerEmail())) {
|
||||||
|
// Wlasciciel nie dostaje powiadomienia o wlasnym ogloszeniu.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!matches(search, listing) || !wantsNotification(email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
notificationService.create(email, "listing", "search",
|
||||||
|
"Nowa oferta z zapisanego wyszukiwania",
|
||||||
|
listing.getTitle() + " - " + listing.getCity() + ", " + price(listing),
|
||||||
|
"listing:" + listing.getId(),
|
||||||
|
"saved-search:" + search.getId() + ":" + listing.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean wantsNotification(String email) {
|
||||||
|
return userRepository.findByEmailIgnoreCase(email)
|
||||||
|
.flatMap(user -> userSettingsRepository.findByUserId(user.getId()))
|
||||||
|
.map(UserSettings::isNotifySavedSearches)
|
||||||
|
.orElse(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Te same kryteria, ktore filtruja liste ofert. Puste pole znaczy "bez ograniczenia",
|
||||||
|
* wiec brak wartosci nigdy nie odrzuca ogloszenia.
|
||||||
|
*/
|
||||||
|
boolean matches(SavedSearch search, PropertyListing listing) {
|
||||||
|
OfferType wanted = "rent".equalsIgnoreCase(search.getMode()) ? OfferType.RENT : OfferType.SALE;
|
||||||
|
if (listing.getOfferType() != wanted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (notBlank(search.getCity()) && !search.getCity().trim().equalsIgnoreCase(listing.getCity())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PropertyType type = propertyType(search.getPropertyType());
|
||||||
|
if (type != null && listing.getPropertyType() != type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Zapisane wyszukiwanie moze dotyczyc rodzaju nieruchomosci, ktorego serwis jeszcze
|
||||||
|
// nie obsluguje (np. dzialka) - wtedy zadne ogloszenie do niego nie pasuje.
|
||||||
|
if (type == null && notBlank(search.getPropertyType()) && !isAnyPropertyType(search.getPropertyType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
BigDecimal price = listing.getPrice();
|
||||||
|
if (price != null) {
|
||||||
|
if (search.getPriceMin() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMin())) < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search.getPriceMax() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMax())) > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Double area = listing.getArea();
|
||||||
|
if (area != null) {
|
||||||
|
if (search.getAreaMin() != null && area < search.getAreaMin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search.getAreaMax() != null && area > search.getAreaMax()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (search.getRooms() != null && listing.getRooms() != null && listing.getRooms() < search.getRooms()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return marketMatches(search, listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaznaczenie obu rynkow (albo zadnego) znaczy "bez znaczenia". */
|
||||||
|
private boolean marketMatches(SavedSearch search, PropertyListing listing) {
|
||||||
|
boolean secondary = search.isSecondaryMarket();
|
||||||
|
boolean primary = search.isPrimaryMarket();
|
||||||
|
if (secondary == primary) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String market = listing.getMarket();
|
||||||
|
if (market == null || market.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String normalized = market.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return secondary ? normalized.startsWith("wt") : normalized.startsWith("pierw");
|
||||||
|
}
|
||||||
|
|
||||||
|
private PropertyType propertyType(String label) {
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = label.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (normalized.startsWith("mieszkan")) {
|
||||||
|
return PropertyType.APARTMENT;
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("dom")) {
|
||||||
|
return PropertyType.HOUSE;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAnyPropertyType(String label) {
|
||||||
|
String normalized = label.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.startsWith("dowoln") || normalized.startsWith("wszystk");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean notBlank(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String price(PropertyListing listing) {
|
||||||
|
BigDecimal price = listing.getPrice();
|
||||||
|
if (price == null) {
|
||||||
|
return "cena do uzgodnienia";
|
||||||
|
}
|
||||||
|
return String.format(Locale.ROOT, "%,d zł", price.longValue()).replace(',', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pl.polskalokalnie.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {
|
||||||
|
|
||||||
|
List<SavedSearch> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
|
||||||
|
|
||||||
|
void deleteByUserEmailIgnoreCase(String userEmail);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import jakarta.mail.internet.MimeMessage;
|
|||||||
import java.io.UnsupportedEncodingException;
|
import java.io.UnsupportedEncodingException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -18,6 +19,14 @@ import pl.polskalokalnie.mailconfig.SmtpConfig;
|
|||||||
@Component
|
@Component
|
||||||
public class EmailSender {
|
public class EmailSender {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo serwisu osadzone w samym mailu (obrazek inline). Szablon odwoluje sie do niego przez
|
||||||
|
* src="cid:...", wiec logo nie zalezy od pobierania z adresu serwisu - dziala tez w mailach
|
||||||
|
* ze srodowiska lokalnego i w programach, ktore blokuja obrazki z zewnatrz.
|
||||||
|
*/
|
||||||
|
public static final String LOGO_CID = "polskalokalnie-logo";
|
||||||
|
private static final String LOGO_RESOURCE = "mail/logo.png";
|
||||||
|
|
||||||
public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) {
|
public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) {
|
||||||
if (config == null || config.getHost() == null || config.getHost().isBlank()
|
if (config == null || config.getHost() == null || config.getHost().isBlank()
|
||||||
|| config.getUsername() == null || config.getUsername().isBlank()) {
|
|| config.getUsername() == null || config.getUsername().isBlank()) {
|
||||||
@@ -26,11 +35,7 @@ public class EmailSender {
|
|||||||
try {
|
try {
|
||||||
JavaMailSenderImpl mailSender = buildMailSender(config);
|
JavaMailSenderImpl mailSender = buildMailSender(config);
|
||||||
MimeMessage message = mailSender.createMimeMessage();
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
|
fillMessage(message, fromAddress(config), to, subject, htmlBody);
|
||||||
helper.setFrom(fromAddress(config));
|
|
||||||
helper.setTo(to);
|
|
||||||
helper.setSubject(subject == null ? "" : subject);
|
|
||||||
helper.setText(htmlBody, true);
|
|
||||||
mailSender.send(message);
|
mailSender.send(message);
|
||||||
String messageId = message.getMessageID();
|
String messageId = message.getMessageID();
|
||||||
return SendResult.ok(messageId);
|
return SendResult.ok(messageId);
|
||||||
@@ -39,6 +44,20 @@ public class EmailSender {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tresc HTML; gdy szablon uzywa logo (cid:), wiadomosc jest wieloczesciowa z logo jako zalacznikiem inline.
|
||||||
|
void fillMessage(MimeMessage message, InternetAddress from, String to, String subject, String htmlBody)
|
||||||
|
throws jakarta.mail.MessagingException {
|
||||||
|
boolean withLogo = htmlBody != null && htmlBody.contains("cid:" + LOGO_CID);
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, withLogo, StandardCharsets.UTF_8.name());
|
||||||
|
helper.setFrom(from);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject == null ? "" : subject);
|
||||||
|
helper.setText(htmlBody, true);
|
||||||
|
if (withLogo) {
|
||||||
|
helper.addInline(LOGO_CID, new ClassPathResource(LOGO_RESOURCE), "image/png");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private JavaMailSenderImpl buildMailSender(SmtpConfig config) {
|
private JavaMailSenderImpl buildMailSender(SmtpConfig config) {
|
||||||
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
|
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
|
||||||
mailSender.setHost(config.getHost());
|
mailSender.setHost(config.getHost());
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ public class MessageComposer {
|
|||||||
|
|
||||||
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
|
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
|
||||||
public String composeEmail(String rawBody, String recipientName, Long leadId) {
|
public String composeEmail(String rawBody, String recipientName, Long leadId) {
|
||||||
String personalized = personalize(rawBody, recipientName);
|
// O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
|
||||||
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
|
// formularza, wiec do tresci HTML trafia escapowane - inaczej wstrzyknelo by link do maila.
|
||||||
|
String html = looksLikeHtml(rawBody)
|
||||||
|
? personalize(rawBody, recipientName == null ? null : escapeHtml(recipientName))
|
||||||
|
: textToHtml(personalize(rawBody, recipientName));
|
||||||
String link = unsubscribeLink(leadId, "email");
|
String link = unsubscribeLink(leadId, "email");
|
||||||
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
|
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
|
||||||
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
|
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
|
||||||
@@ -64,6 +67,7 @@ public class MessageComposer {
|
|||||||
return value
|
return value
|
||||||
.replace("&", "&")
|
.replace("&", "&")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
.replace(">", ">");
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
|
|||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
|
||||||
@@ -20,17 +22,40 @@ public class UnsubscribeController {
|
|||||||
this.unsubscribeService = unsubscribeService;
|
this.unsubscribeService = unsubscribeService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
|
||||||
|
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
|
||||||
|
*/
|
||||||
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||||
public String unsubscribe(@RequestParam("token") String token) {
|
public String confirm(@RequestParam("token") String token) {
|
||||||
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
UnsubscribeService.Result result = unsubscribeService.preview(token);
|
||||||
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
|
||||||
return page("Rezygnacja przyjęta",
|
+ "<button type=\"submit\">Wypisz mnie</button></form>";
|
||||||
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
|
return page("Rezygnacja z wiadomości",
|
||||||
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
|
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
|
||||||
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String page(String title, String message) {
|
/**
|
||||||
|
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
|
||||||
|
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
|
||||||
|
*/
|
||||||
|
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
public String unsubscribe(@RequestParam("token") String token) {
|
||||||
|
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
|
||||||
|
return page("Rezygnacja przyjęta",
|
||||||
|
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
|
||||||
|
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String contact(UnsubscribeService.Result result) {
|
||||||
|
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String channel(UnsubscribeService.Result result) {
|
||||||
|
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String page(String title, String message, String action) {
|
||||||
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
|
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
|
||||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
|
||||||
+ "<title>" + title + "</title>"
|
+ "<title>" + title + "</title>"
|
||||||
@@ -38,7 +63,10 @@ public class UnsubscribeController {
|
|||||||
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
|
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
|
||||||
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
|
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
|
||||||
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
|
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
|
||||||
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>"
|
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
|
||||||
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
|
+ "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
|
||||||
|
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
|
||||||
|
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
|
||||||
|
+ "</div></body></html>";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,28 +41,17 @@ public class UnsubscribeService {
|
|||||||
return encodedPayload + "." + sign(encodedPayload);
|
return encodedPayload + "." + sign(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
|
||||||
|
public Result preview(String token) {
|
||||||
|
Target target = parse(token);
|
||||||
|
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Result unsubscribe(String token) {
|
public Result unsubscribe(String token) {
|
||||||
if (token == null || !token.contains(".")) {
|
Target target = parse(token);
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
Lead lead = target.lead();
|
||||||
}
|
String channel = target.channel();
|
||||||
int dot = token.lastIndexOf('.');
|
|
||||||
String encodedPayload = token.substring(0, dot);
|
|
||||||
String signature = token.substring(dot + 1);
|
|
||||||
if (!sign(encodedPayload).equals(signature)) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
|
||||||
}
|
|
||||||
|
|
||||||
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
|
||||||
String[] parts = payload.split(":", 2);
|
|
||||||
if (parts.length != 2) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
|
||||||
}
|
|
||||||
Long leadId = parseLeadId(parts[0]);
|
|
||||||
String channel = normalizeChannel(parts[1]);
|
|
||||||
|
|
||||||
Lead lead = leadRepository.findById(leadId)
|
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
|
||||||
|
|
||||||
if ("sms".equals(channel)) {
|
if ("sms".equals(channel)) {
|
||||||
lead.setSmsOptOut(true);
|
lead.setSmsOptOut(true);
|
||||||
@@ -76,6 +65,38 @@ public class UnsubscribeService {
|
|||||||
return new Result(channel, maskContact(lead, channel));
|
return new Result(channel, maskContact(lead, channel));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record Target(Lead lead, String channel) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Target parse(String token) {
|
||||||
|
if (token == null || !token.contains(".")) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
int dot = token.lastIndexOf('.');
|
||||||
|
String encodedPayload = token.substring(0, dot);
|
||||||
|
String signature = token.substring(dot + 1);
|
||||||
|
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
|
||||||
|
// ile poczatkowych znakow podpisu sie zgadza.
|
||||||
|
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
|
||||||
|
signature.getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
try {
|
||||||
|
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
String[] parts = payload.split(":", 2);
|
||||||
|
if (parts.length != 2) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
|
||||||
|
}
|
||||||
|
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
|
||||||
|
return new Target(lead, normalizeChannel(parts[1]));
|
||||||
|
}
|
||||||
|
|
||||||
private String sign(String data) {
|
private String sign(String data) {
|
||||||
try {
|
try {
|
||||||
Mac mac = Mac.getInstance(HMAC_ALGO);
|
Mac mac = Mac.getInstance(HMAC_ALGO);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package pl.polskalokalnie.user;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuniecie danych osobowych konta (art. 17 RODO). Dotad kasowany byl tylko wiersz app_users,
|
||||||
|
* ustawienia i ogloszenia - reszta zostawala w bazie jako sieroty: wiadomosci, powiadomienia,
|
||||||
|
* alerty, spotkania, tokeny, kody SMS z numerem telefonu i lead marketingowy.
|
||||||
|
*
|
||||||
|
* <p>Zostaja wylacznie dane, ktorych usuniecie byloby sprzeczne z innym obowiazkiem:
|
||||||
|
* zamowienia promowania (dokumentacja rozliczeniowa), zgloszenia ogloszen i uzytkownikow
|
||||||
|
* oraz korespondencja z obsluga (obrona przed roszczeniami i obowiazki moderacyjne).
|
||||||
|
* Te kategorie maja wlasne okresy przechowywania opisane w Polityce prywatnosci.</p>
|
||||||
|
*
|
||||||
|
* <p>Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie,
|
||||||
|
* tabela po tabeli - dzieki temu widac wprost, co znika.</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AccountErasureService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AccountErasureService.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AccountErasureService(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void eraseFor(Long userId, String email) {
|
||||||
|
if (userId == null || email == null || email.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int removed = 0;
|
||||||
|
|
||||||
|
// Dane powiazane z identyfikatorem konta.
|
||||||
|
removed += update("DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?", userId, userId);
|
||||||
|
removed += update("DELETE FROM conversations WHERE buyer_id = ? OR seller_id = ?", userId, userId);
|
||||||
|
removed += update("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?", userId, userId);
|
||||||
|
removed += update("DELETE FROM meetings WHERE proposer_id = ? OR counterpart_id = ?", userId, userId);
|
||||||
|
|
||||||
|
// Dane powiazane z adresem e-mail.
|
||||||
|
removed += update("DELETE FROM listing_favorites WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM saved_searches WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM price_alert_events WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM price_alerts WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM notifications WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM auth_tokens WHERE lower(user_email) = lower(?)", email);
|
||||||
|
removed += update("DELETE FROM phone_otps WHERE lower(user_email) = lower(?)", email);
|
||||||
|
|
||||||
|
// Baza marketingowa: lead, jego tagi i pozycje w kolejkach kampanii.
|
||||||
|
removed += update("DELETE FROM campaign_recipients WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
|
||||||
|
removed += update("DELETE FROM lead_tags WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
|
||||||
|
removed += update("DELETE FROM leads WHERE lower(email) = lower(?)", email);
|
||||||
|
|
||||||
|
// Poradniki uzytkownika znikaja razem z kontem - tak samo jak jego ogloszenia.
|
||||||
|
removed += update("DELETE FROM article_attachments WHERE article_id IN (SELECT id FROM articles WHERE lower(author_email) = lower(?))", email);
|
||||||
|
removed += update("DELETE FROM articles WHERE lower(author_email) = lower(?)", email);
|
||||||
|
|
||||||
|
log.info("Usunieto {} rekordow powiazanych z kontem id={}", removed, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int update(String sql, Object... args) {
|
||||||
|
try {
|
||||||
|
return jdbcTemplate.update(sql, args);
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
// Brak tabeli w starszej bazie nie moze zablokowac usuniecia konta.
|
||||||
|
log.warn("Nie udalo sie wykonac czyszczenia: {} ({})", sql, ex.getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.PrePersist;
|
import jakarta.persistence.PrePersist;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
|
||||||
import org.hibernate.annotations.ColumnDefault;
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -52,6 +51,9 @@ public class AppUser {
|
|||||||
@Column(length = 30)
|
@Column(length = 30)
|
||||||
private String phone;
|
private String phone;
|
||||||
|
|
||||||
|
// Adres dotyczy wylacznie kont firmowych (siedziba przedsiebiorcy - dana jawna z CEIDG/KRS).
|
||||||
|
// Konto prywatne go nie ma: do kontaktu wystarcza e-mail i telefon, a adres nieruchomosci
|
||||||
|
// stoi przy ogloszeniu. Minimalizacja danych, art. 5 ust. 1 lit. c RODO.
|
||||||
@Column(length = 255)
|
@Column(length = 255)
|
||||||
private String address;
|
private String address;
|
||||||
|
|
||||||
@@ -68,8 +70,6 @@ public class AppUser {
|
|||||||
@Column(length = 20)
|
@Column(length = 20)
|
||||||
private String nip;
|
private String nip;
|
||||||
|
|
||||||
private LocalDate birthDate;
|
|
||||||
|
|
||||||
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
|
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@ColumnDefault("0")
|
@ColumnDefault("0")
|
||||||
@@ -95,11 +95,29 @@ public class AppUser {
|
|||||||
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
|
||||||
private Instant passwordChangedAt;
|
private Instant passwordChangedAt;
|
||||||
|
|
||||||
|
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
|
||||||
|
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
|
||||||
|
private Instant sessionsValidFrom;
|
||||||
|
|
||||||
|
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
|
||||||
|
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
|
||||||
|
private Instant lastActiveAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moment akceptacji Regulaminu i Polityki prywatnosci przy rejestracji. Zapisujemy go,
|
||||||
|
* zeby mozna bylo wykazac, kiedy uzytkownik zaakceptowal dokumenty (art. 7 ust. 1 RODO).
|
||||||
|
*/
|
||||||
|
@Column(name = "terms_accepted_at")
|
||||||
|
private Instant termsAcceptedAt;
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
void onCreate() {
|
void onCreate() {
|
||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
createdAt = Instant.now();
|
createdAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
if (lastActiveAt == null) {
|
||||||
|
lastActiveAt = createdAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -202,14 +220,6 @@ public class AppUser {
|
|||||||
this.nip = nip;
|
this.nip = nip;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getBirthDate() {
|
|
||||||
return birthDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBirthDate(LocalDate birthDate) {
|
|
||||||
this.birthDate = birthDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getPromotionCredits() {
|
public int getPromotionCredits() {
|
||||||
return promotionCredits;
|
return promotionCredits;
|
||||||
}
|
}
|
||||||
@@ -253,4 +263,29 @@ public class AppUser {
|
|||||||
public void setPasswordChangedAt(Instant passwordChangedAt) {
|
public void setPasswordChangedAt(Instant passwordChangedAt) {
|
||||||
this.passwordChangedAt = passwordChangedAt;
|
this.passwordChangedAt = passwordChangedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getSessionsValidFrom() {
|
||||||
|
return sessionsValidFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
|
||||||
|
public void revokeSessions() {
|
||||||
|
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getTermsAcceptedAt() {
|
||||||
|
return termsAcceptedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTermsAcceptedAt(Instant termsAcceptedAt) {
|
||||||
|
this.termsAcceptedAt = termsAcceptedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getLastActiveAt() {
|
||||||
|
return lastActiveAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastActiveAt(Instant lastActiveAt) {
|
||||||
|
this.lastActiveAt = lastActiveAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ public class PublicProfileController {
|
|||||||
? List.of()
|
? List.of()
|
||||||
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
|
||||||
|
|
||||||
|
// Telefon i adres z konta pokazujemy tylko, gdy wlasciciel wlaczyl publiczny kontakt.
|
||||||
|
boolean showContact = settings != null && settings.isPublicContactVisible();
|
||||||
|
|
||||||
return new PublicProfileResponse(
|
return new PublicProfileResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
user.getFullName(),
|
user.getFullName(),
|
||||||
@@ -80,6 +83,15 @@ public class PublicProfileController {
|
|||||||
user.getAccountType(),
|
user.getAccountType(),
|
||||||
user.isVerified(),
|
user.isVerified(),
|
||||||
user.getCreatedAt(),
|
user.getCreatedAt(),
|
||||||
|
user.getLastActiveAt(),
|
||||||
|
settings == null ? null : settings.getCompanyTagline(),
|
||||||
|
settings == null ? null : settings.getWebsite(),
|
||||||
|
settings == null ? null : settings.getSocialLinkedin(),
|
||||||
|
settings == null ? null : settings.getSocialFacebook(),
|
||||||
|
settings == null ? null : settings.getSocialInstagram(),
|
||||||
|
settings == null ? null : settings.getSocialYoutube(),
|
||||||
|
showContact ? user.getPhone() : null,
|
||||||
|
showContact ? user.getAddress() : null,
|
||||||
listings.size(),
|
listings.size(),
|
||||||
listings.stream()
|
listings.stream()
|
||||||
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
.map(listing -> new PublicProfileResponse.PublicProfileListing(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user