Compare commits

27 Commits

Author SHA1 Message Date
debian 48dba240ee Zakladki wyszukiwarki nie zaslaniaja juz korzysci w hero
Deploy Polska Lokalnie / deploy (push) Successful in 21s
Sekcja hero miala sztywna wysokosc 700 px, a naglowek skaluje sie z szerokoscia okna
(clamp do 78 px). Na monitorach od ok. 1860 px rzad korzysci schodzil nizej i wchodzil
pod panel wyszukiwarki, podciagany o stale 194 px od dolnej krawedzi hero.

Wysokosc hero wynika teraz z tresci (min-height zamiast height) plus dolny odstep
210 px = 194 px podciagniecia panelu i 16 px przeswitu. Odstep jest staly niezaleznie
od szerokosci okna; ponizej 1760 px nic sie nie zmienia.

Test mierzy nachodzenie na siedmiu szerokosciach od 1280 do 2560 px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 13:36:52 +02:00
debian d9819d0488 Pomoc z rysunkami ekranow, kafelki na cala szerokosc, kalkulator na danych GUS i Eurostatu
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Pomoc i kontakt:
- kanaly kontaktu wypelniaja szerokosc panelu (auto-fit zamiast czterech kolumn)
- FAQ podzielone na cztery moduly, 21 pytan, ze schematycznymi rysunkami ekranow
  (inline SVG w helpIllustrations.tsx - nie zrzuty, wiec nie dezaktualizuja sie)
- menu konta nie przykleja sie juz nad trescia na telefonie i tablecie

Kafelki wyboru:
- kolumna nie schodzi ponizej czytelnej szerokosci, podpisy lamia sie miedzy slowami
  zamiast po literach (bylo widoczne na monitorach ok. 1440 px)

Kalkulator zdolnosci kredytowej:
- rodzaj oprocentowania decyduje o buforze: 2,5 p.p. przy stalym, 5 p.p. przy zmiennym
  (wczesniej zawsze 2,5 p.p., co zawyzalo zdolnosc przy zmiennej stopie o ok. 100 tys. zl)
- nowy modul market: przecietne wynagrodzenie z GUS BDL i stopa rynku pienieznego 3M
  z Eurostatu, odswiezane co tydzien, publiczny GET /api/market-indicators
- prog wyzszego DStI i podpowiadane oprocentowanie licza sie z tych wskaznikow,
  z podpisem zrodla i okresu; brak danych = wartosci zapasowe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:13:10 +02:00
debian cf02f480ed górne menu - wiadomości
Deploy Polska Lokalnie / deploy (push) Successful in 22s
2026-09-25 10:09:33 +02:00
debian 517e80d6bd Historia cen - liczby takze tam, gdzie nie ma jeszcze ogloszen
Deploy Polska Lokalnie / deploy (push) Successful in 21s
Dzielnica bez wlasnych ogloszen miala na wykresie pelne serie NBP i GUS,
ale naglowek i wszystkie cztery rubryki pokazywaly kreski.

- brak ogloszen: naglowek i rubryki licza srednia z NBP i GUS w kwartale
- podpis mowi wprost, ze to dane transakcyjne dla calego miasta
- tytul, cena i zmiana procentowa staja w jednej linii
- "Zmiana w okresie" przy jednym zapisie ma podpis "Za krotka historia"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 08:47:57 +02:00
debian 09d4b2f32b Nawigacja mobilna - dolny pasek i menu w szufladzie
Deploy Polska Lokalnie / deploy (push) Successful in 17s
Gorne menu na telefonie mialo poziomy pasek przewijania, a najwazniejsze
skroty nie mialy sie gdzie zmiescic.

- dolny pasek (<= 760 px): Start, Szukaj, Dodaj, Ulubione, Konto
- pasek dzialow (<= 1100 px) przenosi sie do szuflady pod przyciskiem menu
- w naglowku na telefonie zostaja wiadomosci, dzwonek i menu
- kafelki narzedzi schodza do dwoch i jednej kolumny
- karty hero wracaja do przeplywu, zamiast nachodzic na liste korzysci
- spis tresci stron prawnych nie rozpycha juz strony przez tabele

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 07:11:14 +02:00
debian c9c78b6db5 Zdjecie na stronach logowania siega gornego menu
Deploy Polska Lokalnie / deploy (push) Successful in 20s
Na /logowanie i pozostalych ekranach z tym ukladem (rejestracja, odzyskiwanie
hasla) zdjecie stalo w dolnej czesci lewej kolumny, a nad nim byl bialy pas.
Teraz wypelnia cala kolumne od gornego menu - tak jak hero na stronie glownej -
a tresc lezy na nim pod biala poswiata, ktora ku dolowi znika.

Na waskim ekranie zdjecie zostaje osobnym kadrem na gorze kolumny, bez poswiaty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 22:11:54 +02:00
debian ca5f3ebfc1 Regulamin i polityka prywatnosci zgodne ze stanem serwisu
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Audyt pokazal rozbieznosci w obie strony: dokumenty opisywaly funkcje,
ktorych nie ma (logowanie Google/Facebook), a kod robil rzeczy, ktorych
dokumenty nie ujawnialy.

Kod:
- zgody marketingowe domyslnie WYLACZONE (byly true dla kazdego nowego
  konta) + skrypt fix-marketing-consents.sql czyszczacy stare wpisy,
- usuniete tlumaczenie przez nieoficjalne API Google - przy zmianie
  jezyka wysylalo do USA tresci stron, w tym wiadomosci i nazwiska,
- osadzone spacery (YouTube, Matterport, Kuula) laduja sie dopiero po
  zgodzie; YouTube w trybie bez ciasteczek; mapy jak dotad,
- usuniecie konta kasuje dane osobowe (AccountErasureService), a nie
  tylko wiersz app_users,
- wygasle tokeny i kody SMS kasowane po 7 dniach (ExpiredTokenCleaner),
- zapisujemy moment akceptacji regulaminu przy rejestracji.

Dokumenty: opisuja realne funkcje, wszystkich odbiorcow danych (OVH,
Autopay, SMTP, SMS, Esri, OpenStreetMap, YouTube/Matterport/Kuula, GUS),
transfery poza EOG, konkretne okresy przechowywania, procedure zgloszen
z DSA, brak profilowania i tabele pamieci przegladarki zamiast fikcyjnych
cookies analitycznych. Zakres NIS2 i DORA opisany zgodnie z prawda.

Uklad stron prawnych jak reszta serwisu: kontener 1760 px, karty,
spis tresci z boku.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:59:15 +02:00
debian a4457df5f8 Alerty cenowe na serwerze - powiadomienia tylko w serwisie
Deploy Polska Lokalnie / deploy (push) Successful in 22s
Alerty liczyla dotad przegladarka, wiec dzialaly wylacznie przy otwartej
stronie, a rynek porownywaly malymi literami ("wtorny" vs "Wtórny"),
przez co alert lokalizacji z wybranym rynkiem nigdy nie mial danych.

Teraz: tabele price_alerts i price_alert_events, /api/price-alerts oraz
codzienny przeglad o 03:00 (po nocnym zapisie srednich cen). Ogloszenie -
powiadomienie przy kazdej zmianie ceny; lokalizacja - przy zmianie
sredniej o co najmniej 2%. Wynik trafia wylacznie na liste powiadomien
uzytkownika; alerty nie wysylaja zadnych e-maili.

Alerty zalozone w przegladarce przenosimy raz przy pierwszym wejsciu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:00:40 +02:00
debian 008223f311 Spring Boot 3.5.16, testy backendu na PostgreSQL (Testcontainers)
Deploy Polska Lokalnie / deploy (push) Successful in 29s
- Spring Boot 3.3.5 -> 3.5.16 (linia 3.3 bez poprawek bezpieczenstwa).
- Testy zamiast H2 uzywaja tymczasowego PostgreSQL 16 (Testcontainers),
  tej samej wersji co produkcja; ddl-auto update jak na produkcji.
  Testcontainers 1.21 (z Boot 3.5) jest wymagany - 1.19 z Boot 3.3 nie
  dziala z obecnym Docker Desktop, dlatego obie zmiany w jednym commicie.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:31:03 +02:00
debian ad1d573e89 Drobne poprawki bezpieczenstwa z audytu
Deploy Polska Lokalnie / deploy (push) Successful in 12s
- /api/listings/favorites i /mine/** wymagaja logowania (anonimowo byl blad 500).
- Nowa rozmowa tylko o opublikowanym ogloszeniu.
- Licznik wyswietlen: jedno wyswietlenie na IP i ogloszenie na godzine.
- Rezygnacja z mailingu: GET pyta o potwierdzenie, wypisuje dopiero POST
  (tez z przycisku programu pocztowego); podpis porownywany w stalym czasie.
- Usuniecie konta usuwa jego ogloszenia.
- Haslo przy rejestracji min. 8 znakow; resend-phone-otp nie zdradza, czy konto istnieje.
- docker-compose.prod.yml bez domyslnego Admin123!; bez hasla konto admina
  nie jest zakladane (blad w logu).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 17:03:05 +02:00
debian a53cae7c37 Istotna edycja zatwierdzonego ogloszenia wraca do moderacji
Deploy Polska Lokalnie / deploy (push) Successful in 12s
- Zmiana tytulu, opisu, zdjec, danych kontaktowych lub linku spaceru
  w ogloszeniu opublikowanym albo wstrzymanym ustawia status PENDING;
  cena i parametry zmieniaja sie od razu.
- Frontend nie koduje ponownie zapisanych zdjec JPEG przy edycji (bez utraty
  jakosci i bez falszywej zmiany zdjec); informacja o weryfikacji w formularzu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:39:15 +02:00
debian b850e4c092 Poprawki bezpieczenstwa: maile, kredyty, token strumienia, lokalizacja, spotkania, zgloszenia
Deploy Polska Lokalnie / deploy (push) Successful in 13s
- Imie, tytul artykulu i powod odrzucenia oraz {{name}} w kampaniach
  escapowane w HTML maili; limit dlugosci imienia.
- Kredyty promowania zuzywane i dopisywane warunkowym UPDATE, ITN blokuje
  wiersz zamowienia; zakup i kredyt tylko na aktywny plan/pakiet.
- Strumien powiadomien na krotkim (60 s) tokenie ?ticket= pobieranym przez
  POST /api/notifications/stream-token; token sesji nie jest przyjmowany z adresu.
- Przyblizona lokalizacja jako srodek kratki 300 m zamiast odwracalnego
  przesuniecia liczonego z id.
- Propozycja spotkania respektuje blokade i moderacje, bez e-maila w nazwie.
- Zglaszajacy dostaje samo potwierdzenie; zgloszenia tylko opublikowanych
  ogloszen; nie zapisujemy rozmowy przyslanej przez przegladarke.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:36:48 +02:00
debian 0d149467e9 Rate limiting w nginx na prawdziwym IP klienta
Deploy Polska Lokalnie / deploy (push) Successful in 5s
- realip: IP klienta z X-Real-IP ustawianego przez nginx hosta, zaufanie tylko
  z sieci prywatnych; limitowany jest wylacznie ruch przez nginx hosta.
- Limity na IP: logowanie/hasla 10/min, akcje wysylajace mail/SMS 3/min,
  tlumaczenia 120/min, cale API 30/s; 429 jako JSON z komunikatem.
- Wspolne ustawienia proxy w nginx/api-proxy.conf.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:18:31 +02:00
debian a6c2378cbc Naglowki bezpieczenstwa w nginx, wymagany sekret JWT na produkcji, npm ci
Deploy Polska Lokalnie / deploy (push) Successful in 14s
- security-headers.conf: nosniff, X-Frame-Options, Referrer-Policy, HSTS,
  Permissions-Policy i minimalny CSP; dolaczany w kazdym location z add_header,
  bez dublowania naglowkow Spring Security na /api/.
- docker-compose.prod.yml: APP_JWT_SECRET bez wartosci domyslnej - brak zmiennej
  zatrzymuje wdrozenie zamiast startu z jawnym sekretem.
- Obraz frontendu budowany przez npm ci z package-lock.json.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:12:52 +02:00
debian ac7a3a4efb Sesje sprawdzane w bazie, e-mail ogloszeniodawcy niepubliczny, zgody z formularza
Deploy Polska Lokalnie / deploy (push) Successful in 12s
- JwtAuthFilter bierze role i stan konta z bazy: blokada, usuniecie konta
  i odebranie roli admina dzialaja od razu. Zmiana i reset hasla koncza
  dotychczasowe sesje (sessionsValidFrom); zmiana hasla zwraca nowy token.
- Publiczne odpowiedzi ogloszen bez ownerEmail/contactEmail; inne oferty
  sprzedajacego z GET /api/listings/{id}/seller-listings.
- Publiczny formularz reklamowy nie zmienia zgod ani nazwy istniejacego leada.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:56:36 +02:00
debian 3a555e3b24 AutoPay wg rozwiazania z Gieldy Komunalnej
Deploy Polska Lokalnie / deploy (push) Successful in 11s
- ITN: XML w base64 (pole transactions), bezpieczny parser (bez XXE), podpis
  ze stalej listy pol, zgodnosc ServiceID, kwoty i waluty, idempotencja,
  podpisana odpowiedz XML CONFIRMED/NOTCONFIRMED.
- Start transakcji podpisanym formularzem POST ze strony /platnosc/:orderId
  zamiast linku GET z podpisem w adresie; tytul przelewu w ASCII.
- Losowy OrderID (UUID) zamiast Math.random.
- Adres ITN do wpisania w panelu AutoPay widoczny w panelu admina.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:22:20 +02:00
debian 1c1162c93e Poprawki bezpieczenstwa: XSS przez zdjecia, darmowe promowanie, ITN
Deploy Polska Lokalnie / deploy (push) Successful in 12s
- Zdjecia ogloszen tylko jako data URL JPEG/PNG/WebP; podglad OG serwuje
  wylacznie te typy z nosniff i CSP sandbox, bez przekierowania na dowolny URL.
- Zakup promowania bez skonfigurowanej bramki AutoPay zwraca 503; automatyczne
  potwierdzanie tylko przy APP_PAYMENTS_AUTO_CONFIRM (lokalnie).
- ITN uznaje platnosc tylko przy jawnym statusie SUCCESS i zgodnej kwocie.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:13:29 +02:00
debian c740612b06 "Podglad reklamy" wyglada jak szkic, a nie dzialajacy profil
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Panel na /firmy-i-uslugi mial 9 klikalnych przyciskow bez akcji oraz
zaszyte dane cudzej firmy ("4.8 (96 opinii)", "12 lat na rynku",
"230+ zrealizowanych projektow"). Teraz nic nie jest klikalne, liczby
zastapily podpisy pol, a na okladce jest plakietka "Podglad".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 12:43:34 +02:00
debian d4dd898387 Sprawdzone lokalizacje w Historii cen zapisane na koncie
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Lista ostatnio sprawdzonych lokalizacji zyla w localStorage, wiec ginela
przy zmianie urzadzenia. Teraz jest polem ustawien konta
(user_settings.price_history_markets), a wpisy z przegladarki
przenosimy raz przy pierwszym wejsciu.

To historia przegladania, nie alerty - nic z niej nie wysyla powiadomien.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 12:21:39 +02:00
debian b5d121e24e Kampanie w panelu admina naprawde sie wysylaja
Deploy Polska Lokalnie / deploy (push) Successful in 10s
Outbox byl budowany, ale nic go nie przetwarzalo - kampania wchodzila
w status SENDING i tak zostawala. CampaignSender co minute bierze
kampanie gotowe do wyslania i wysyla je porcjami po 25, pilnujac
dziennego limitu kampanii.

Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie (zgoda
mogla zostac cofnieta po zbudowaniu outboxu) - taki dostaje OPTED_OUT.
Kazda wiadomosc ma link rezygnacji. Bez skonfigurowanej skrzynki
kolejka czeka, zamiast spalic sie na bledach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 12:13:07 +02:00
debian 036cb59331 Skrypt czyszczacy kampanie marketingowe
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Usuwa wszystkie kampanie i ich kolejki odbiorcow, zeby stare kampanie
zawieszone w statusie SENDING nie ruszyly po wdrozeniu wysylki.
Leady, zgody marketingowe i grupy docelowe zostaja nietkniete.

Domyslnie proba na sucho (ROLLBACK), zapis tylko z -v apply=1 -
tak samo jak scripts/cleanup-test-data.sql.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 12:12:23 +02:00
debian dbb6cf58d0 Testy backendu przechodza - poprawki schematu tylko na PostgreSQL
SchemaFixer wykonuje SQL w skladni PostgreSQL (split_part, DO $$,
to_regclass), wiec kazdy @SpringBootTest wywracal sie przy starcie na
testowym H2. Teraz runner sprawdza rodzaj bazy i na innej niz PostgreSQL
nic nie robi - w testach schemat i tak tworzy Hibernate z encji.

mvn test: 22 testy, 0 bledow (bylo 3 bledy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:56:43 +02:00
debian 45a32d7ef0 Historia ogladania i uklad panelu osobno dla kazdego konta
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Oba klucze byly globalne dla przegladarki, wiec na wspolnym komputerze
jedno konto widzialo, czego szukalo drugie, i nadpisywalo mu uklad
kafelkow. Teraz oba siedza pod accountProfile:<id>:*.

Zapis do historii czeka na zakonczenie wczytywania konta - wczesniej
oferta otwarta zaraz po wejsciu trafiala do historii "goscia".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:43:44 +02:00
debian d6b6e3edd9 Teksty zgodne z tym, co serwis naprawde robi
Deploy Polska Lokalnie / deploy (push) Successful in 11s
- /jak-sprzedawac: zamiast Google, Facebook Marketplace, "ponad 500 grup"
  i newslettera "do tysiecy subskrybentow" - lista ofert, mapa, podglad
  linku w komunikatorach (OgController) i platne wyroznienie.
- "Dodaj do 30 zdjec i film" -> 8 zdjec, tyle przyjmuje backend; wideo nie ma.
- "Pomocny asystent AI" -> poradniki, wycena i kontakt z obsluga.
- Zdania o "tysiacach kupujacych/czytelnikow/klientow" przepisane bez
  twierdzen o skali (10 miejsc).
- Ustawienia: przelaczniki maili, ktorych nikt nie wysyla (zapisane
  wyszukiwania, alerty, nowosci), nieaktywne z wyjasnieniem.
- "Zweryfikowana firma" -> "Adres e-mail potwierdzony" - bo tym to jest.
- Poradniki: "Powiazane oferty" prowadza do zapowiadanych wynikow,
  pasek "Popularne" do poradnikow, a nie do list ofert.
- Kafelek 360 na stronie glownej bez plakietki "Wkrotce" - spacer dziala.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:34:39 +02:00
debian 557c76257b Ulubione na koncie - koniec kasowania zapisow przez pusta przegladarke
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Dotad front po kazdej zmianie dyktowal serwerowi cala liste ulubionych.
Zalogowanie sie na innym urzadzeniu wysylalo "mam zero ulubionych"
i kasowalo zapisy, przez co wlasciciel ogloszenia tracil licznik zapisan.

Teraz: GET /api/listings/favorites zwraca ulubione konta, dodanie
i usuniecie ida pojedynczo (POST/DELETE /{id}/favorite), a wpisy z
przegladarki dopisuje jednorazowy /favorites/import. Endpoint
/favorites/sync usuniety razem z jego jedynym wywolaniem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:26:00 +02:00
debian b9fecdea85 Spotkania na serwerze: umawianie ogladania z druga strona
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Nowa tabela meetings i /api/meetings. Termin proponuje jedna ze stron
rozmowy o ogloszeniu, druga potwierdza albo odrzuca; odwolac moze kazda
ze stron. Kalendarz konta pokazuje oba rodzaje wpisow - umowione
ogladania i wlasne notatki - i jest ten sam na kazdym urzadzeniu.
Powiadomienia: wpis w watku i dzwonek przy kazdej zmianie, e-mail
wylacznie przy potwierdzeniu terminu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:57:35 +02:00
debian e85cc70982 Zapisane wyszukiwania na koncie zamiast w przegladarce
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Nowa tabela saved_searches i /api/saved-searches (lista, dodanie,
usuniecie, jednorazowy import wpisow z localStorage). Front czyta liste
z serwera, wiec wyszukiwania sa te same na kazdym urzadzeniu i przezywaja
wyczyszczenie danych strony. Bez powiadomien. Skrypt czyszczacy usuwa
wpisy skasowanych kont.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 09:10:20 +02:00
121 changed files with 9437 additions and 2151 deletions
+5
View File
@@ -27,3 +27,8 @@ 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
+4 -3
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<version>3.5.16</version>
<relativePath/>
</parent>
@@ -64,9 +64,10 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testy na prawdziwym PostgreSQL (tymczasowy kontener), tak jak produkcja - bez emulacji H2. -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
import pl.polskalokalnie.support.SupportMessageResponse;
import pl.polskalokalnie.support.SupportMessageService;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.BlockedEmail;
import pl.polskalokalnie.user.BlockedEmailRepository;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@RestController
@RequestMapping("/api/admin")
@@ -64,6 +66,8 @@ public class AdminController {
private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder;
private final NotificationService notificationService;
private final AccountErasureService accountErasureService;
private final UserSettingsService userSettingsService;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService,
@@ -74,7 +78,9 @@ public class AdminController {
TextModerationService textModerationService,
AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder,
NotificationService notificationService) {
NotificationService notificationService,
AccountErasureService accountErasureService,
UserSettingsService userSettingsService) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService;
@@ -87,6 +93,8 @@ public class AdminController {
this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService;
this.accountErasureService = accountErasureService;
this.userSettingsService = userSettingsService;
}
// --- Uzytkownicy ---
@@ -166,6 +174,8 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
user.revokeSessions();
userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane",
@@ -181,6 +191,7 @@ public class AdminController {
if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -71,7 +71,7 @@ public class AccountEmailService {
boolean approved, String reason, String path) {
String link = baseUrl + (path == null ? "" : path);
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;
if (approved) {
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
@@ -80,7 +80,7 @@ public class AccountEmailService {
} else {
String reasonHtml = reason == null || reason.isBlank() ? ""
: "<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ół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
+ reasonHtml
@@ -106,6 +106,28 @@ public class AccountEmailService {
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
}
/**
* Potwierdzony termin ogladania. Jedyny e-mail modulu spotkan - propozycje, odmowy
* i odwolania zostaja w serwisie (watek rozmowy i dzwonek).
*/
public void sendMeetingConfirmed(String email, String name, String otherName, String listingTitle,
String when, String location, Long conversationId) {
String link = baseUrl + (conversationId == null ? "/konto/spotkania" : "/wiadomosci/" + conversationId);
String where = location == null || location.isBlank() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Miejsce:</strong> " + HtmlUtils.htmlEscape(location) + "</p>";
String about = listingTitle == null || listingTitle.isBlank() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Ogłoszenie:</strong> " + HtmlUtils.htmlEscape(listingTitle) + "</p>";
String body = "<p>Termin oglądania został potwierdzony przez: <strong>"
+ HtmlUtils.htmlEscape(otherName) + "</strong>.</p>"
+ "<div style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;border-radius:6px;\">"
+ "<p style=\"margin:0 0 4px\"><strong>Termin:</strong> " + HtmlUtils.htmlEscape(when) + "</p>"
+ where + about + "</div>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Jeśli termin przestanie pasować, odwołaj go w serwisie -"
+ " druga strona od razu to zobaczy.</p>";
send(email, "Potwierdzone oglądanie: " + when,
template("Termin potwierdzony", greeting(name), body, "Zobacz szczegóły", link), "SPOTKANIE", link);
}
private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled()
@@ -121,8 +143,10 @@ public class AccountEmailService {
}
}
private static String greeting(String name) {
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
// Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
// 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>";
}
@@ -112,10 +112,10 @@ public class AuthController {
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
}
/**
@@ -3,8 +3,6 @@ package pl.polskalokalnie.auth;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Locale;
@@ -14,6 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -21,6 +20,7 @@ import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
@@ -52,6 +52,8 @@ public class AuthService {
private final PhoneVerificationService phoneVerificationService;
private final NickService nickService;
private final UserSettingsService userSettingsService;
private final AccountErasureService accountErasureService;
private final ListingService listingService;
public AuthService(
UserRepository userRepository,
@@ -64,7 +66,9 @@ public class AuthService {
AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService,
NickService nickService,
UserSettingsService userSettingsService
UserSettingsService userSettingsService,
AccountErasureService accountErasureService,
ListingService listingService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
@@ -77,6 +81,8 @@ public class AuthService {
this.phoneVerificationService = phoneVerificationService;
this.nickService = nickService;
this.userSettingsService = userSettingsService;
this.accountErasureService = accountErasureService;
this.listingService = listingService;
}
@Transactional
@@ -104,7 +110,14 @@ public class AuthService {
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
user.setPhone(phone);
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.
user.setVerified(false);
user.setPhoneVerified(false);
@@ -191,6 +204,8 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
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.
user.setVerified(true);
userRepository.save(user);
@@ -210,8 +225,12 @@ public class AuthService {
*/
public void resendPhoneOtp(String email) {
String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
// 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()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
}
@@ -289,7 +308,7 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.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());
if (request.nick() != null && !request.nick().isBlank()) {
@@ -303,22 +322,20 @@ public class AuthService {
user.setPhoneVerified(false);
}
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.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);
leadSyncService.syncUser(saved);
@@ -330,7 +347,7 @@ public class AuthService {
* wiec przejeta sesja nie pozwala przejac konta na stale.
*/
@Transactional
public void changePassword(String email, String currentPassword, String newPassword) {
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -357,6 +374,8 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
user.revokeSessions();
userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -366,6 +385,7 @@ public class AuthService {
authTokenRepository.save(old);
}
});
return buildAuthResponse(user);
}
/**
@@ -429,7 +449,9 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
}
accountErasureService.eraseFor(user.getId(), user.getEmail());
userSettingsService.deleteForUser(user.getId());
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -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.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import org.springframework.lang.NonNull;
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.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Component
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.userRepository = userRepository;
}
@Override
@@ -30,16 +37,29 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null) {
String header = request.getHeader("Authorization");
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 {
Claims claims = jwtService.parse(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
if (streamToken == fromHeader) {
// Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
}
// 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) {
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
SecurityContextHolder.clearContext();
@@ -48,16 +68,12 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
static boolean isSessionValid(AppUser user, Instant issuedAt) {
if (user.isBlocked() || user.getRole() == null) {
return false;
}
String param = request.getParameter("access_token");
if (param != null && !param.isBlank()) {
return param;
}
return null;
Instant validFrom = user.getSessionsValidFrom();
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
}
}
@@ -38,6 +38,26 @@ public class JwtService {
.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) {
return Jwts.parser()
.verifyWith(key)
@@ -39,18 +39,24 @@ public class SecurityConfig {
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
"/api/auth/forgot-password", "/api/auth/reset-password",
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
.requestMatchers("/api/i18n/translate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").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.POST, "/api/unsubscribe").permitAll()
.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()
// 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.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
@@ -7,11 +7,13 @@ import pl.polskalokalnie.user.AccountType;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 6, max = 72) String password,
@NotBlank String fullName,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 120) String fullName,
AccountType accountType,
String phone,
String nip,
String address
String address,
/** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */
Boolean acceptTerms
) {
}
@@ -1,15 +1,16 @@
package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import pl.polskalokalnie.user.ContactPreference;
import pl.polskalokalnie.user.PreferredLanguage;
public record UpdateProfileRequest(
@NotBlank String fullName,
@NotBlank @Size(max = 120) String fullName,
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
String nick,
String phone,
String birthDate,
// Adres siedziby - przyjmowany tylko od kont firmowych.
String address,
// NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u);
// pusty ciag = wyczysc. Dotyczy kont firmowych.
@@ -1,7 +1,6 @@
package pl.polskalokalnie.auth.dto;
import java.time.Instant;
import java.time.LocalDate;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
@@ -22,7 +21,6 @@ public record UserResponse(
ContactPreference contactPreference,
PreferredLanguage preferredLanguage,
String nip,
LocalDate birthDate,
boolean verified,
boolean phoneVerified,
boolean blocked,
@@ -50,7 +48,6 @@ public record UserResponse(
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
user.getNip(),
user.getBirthDate(),
user.isVerified(),
user.isPhoneVerified(),
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
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
* wysylajacego dodajemy w kolejnej fazie.
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
* Wysylke z outboxu obsluguje {@link CampaignSender}.
*/
@Transactional
public CampaignResponse schedule(Long id) {
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
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);
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
if (!adminResetPassword) {
return;
}
if (passwordMissing) {
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
return;
}
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
@@ -1,5 +1,10 @@
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -8,9 +13,32 @@ import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
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
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
return args -> {
if (!isPostgres(jdbcTemplate)) {
return;
}
jdbcTemplate.execute(
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
);
@@ -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)
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)
@ColumnDefault("true")
private boolean emailConsent = true;
@ColumnDefault("false")
private boolean emailConsent = false;
@Column(nullable = false)
@ColumnDefault("true")
private boolean smsConsent = true;
@ColumnDefault("false")
private boolean smsConsent = false;
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
@Column(nullable = false)
@@ -231,7 +231,11 @@ public class LeadService {
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());
tags.add(ADVERTISING_TAG);
@@ -239,9 +243,12 @@ public class LeadService {
lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
if (lead.getId() == null) {
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
}
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead);
}
@@ -1,5 +1,6 @@
package pl.polskalokalnie.listing;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
public class ListingController {
private final ListingService listingService;
private final ListingViewGuard viewGuard;
public ListingController(ListingService listingService) {
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
this.listingService = listingService;
this.viewGuard = viewGuard;
}
/**
@@ -40,6 +43,12 @@ public class ListingController {
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")
public List<ListingResponse> mine(Authentication authentication) {
return listingService.findMine(authentication.getName());
@@ -50,12 +59,16 @@ public class ListingController {
public ListingDetailResponse getById(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean incrementView,
Authentication authentication
Authentication authentication,
HttpServletRequest request
) {
String viewerEmail = authentication == null ? null : authentication.getName();
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
.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
@@ -69,7 +82,7 @@ public class ListingController {
.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}")
public ListingDetailResponse update(
@PathVariable Long id,
@@ -72,18 +72,22 @@ public record ListingDetailResponse(
private static final double METERS_PER_DEGREE = 111_320d;
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return from(listing, ownerId, ownerAvatar, null, null, null, null, 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
* 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
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
* zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
* 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,
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
boolean masked) {
boolean masked, boolean publicView) {
String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress();
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
listing.getOwnership(),
listing.getContactName(),
listing.getContactPhone(),
listing.getContactEmail(),
publicView ? null : listing.getContactEmail(),
listing.getRentExtra(),
listing.getAvailableFrom(),
listing.getFurnishing(),
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(),
listing.getOwnerEmail(),
publicView ? null : listing.getOwnerEmail(),
ownerId,
ownerAvatar,
ownerAccountType,
@@ -147,31 +151,33 @@ public record ListingDetailResponse(
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) {
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) {
return null;
}
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
if (parallel == 0) {
// Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
// 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() + (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,
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
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);
private static double snapToCellCenter(double value, double cellDegrees) {
return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
}
}
@@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
/**
* Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI),
* a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte.
* Ulubione ogloszenia uzytkownika - trzymane na koncie, wiec sa te same na kazdym urzadzeniu.
* Z tych samych danych liczymy wlascicielowi, ile osob zapisalo jego oferte.
*/
@RestController
@RequestMapping("/api/listings")
@@ -49,30 +49,38 @@ public class ListingFavoriteController {
return ResponseEntity.noContent().build();
}
/**
* Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie
* i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego,
* z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie.
*/
@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)
/** Ulubione zalogowanego uzytkownika - te same na kazdym urzadzeniu. */
@GetMapping("/favorites")
public List<ListingResponse> myFavorites(Authentication authentication) {
List<Long> listingIds = favoriteRepository.findByUserEmailIgnoreCase(authentication.getName()).stream()
.map(ListingFavorite::getListingId)
.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()))
.forEach(favoriteRepository::delete);
wanted.forEach(listingId -> save(listingId, userEmail));
return ResponseEntity.noContent().build();
/**
* Dopisanie ulubionych zapisanych wczesniej w przegladarce. Tylko dodaje - nigdy nie kasuje,
* zeby wejscie z pustej przegladarki nie wyczyscilo listy zebranej na innym urzadzeniu.
*/
@PostMapping("/favorites/import")
@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}. */
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
}
@@ -30,15 +30,20 @@ public record ListingResponse(
Instant promotedUntil,
Instant createdAt
) {
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
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
* ogladanych spoza panelu wlasciciela i administracji.
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
* 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 address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
@@ -60,7 +65,7 @@ public record ListingResponse(
listing.getMarket(),
listing.getYearBuilt(),
listing.getCoverPhoto(),
listing.getOwnerEmail(),
ownerEmail,
listing.getStatus(),
listing.getViewsCount(),
listing.getPromotedUntil(),
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.search.SavedSearchNotifier;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@@ -28,15 +29,17 @@ public class ListingService {
private final NotificationService notificationService;
private final UserRepository userRepository;
private final UserSettingsService userSettingsService;
private final SavedSearchNotifier savedSearchNotifier;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService, UserRepository userRepository,
UserSettingsService userSettingsService) {
UserSettingsService userSettingsService, SavedSearchNotifier savedSearchNotifier) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
this.userRepository = userRepository;
this.userSettingsService = userSettingsService;
this.savedSearchNotifier = savedSearchNotifier;
}
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
@@ -52,21 +55,22 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
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
* 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);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
}
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);
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
}
/**
@@ -97,7 +101,7 @@ public class ListingService {
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.map(ListingResponse::publicFrom)
.toList();
}
@@ -134,8 +138,29 @@ public class ListingService {
listing = listingRepository.save(listing);
}
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
// 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) {
@@ -161,7 +186,11 @@ public class ListingService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
}
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
@@ -194,8 +223,8 @@ public class ListingService {
return detailOf(saved);
}
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian.
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem; istotna zmiana tresci odsyla ogloszenie
// do ponownej weryfikacji, a drobne zmiany (cena, metraz, parametry) sa widoczne od razu.
@Transactional
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
PropertyListing listing = listingRepository.findById(id)
@@ -206,13 +235,27 @@ public class ListingService {
request.title(), request.description(), request.city(), request.address(),
request.district(), request.street(), request.contactName());
List<Object> before = moderatedContent(listing);
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);
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).
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
listing.setTitle(request.title().trim());
@@ -273,6 +316,11 @@ public class ListingService {
listing.setStatus(status);
PropertyListing saved = listingRepository.save(listing);
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);
}
@@ -306,6 +354,17 @@ public class ListingService {
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) {
if (value == null) {
return null;
@@ -327,11 +386,20 @@ public class ListingService {
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<>();
if (photos != null) {
for (String photo : photos) {
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);
if (result.size() >= MAX_PHOTOS) {
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();
}
}
@@ -48,19 +48,23 @@ public class ConversationMessageNotifier {
@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);
boolean wantsEmail = userSettingsRepository.findByUserId(event.recipientId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (wantsEmail) {
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
event.listingTitle(), event.content(), event.conversationId());
}
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());
}
@@ -59,6 +59,12 @@ public class ConversationService {
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) {
@@ -167,6 +173,26 @@ public class ConversationService {
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
}
/**
* Wpis o spotkaniu w watku rozmowy. Swiadomie NIE publikuje zdarzenia o nowej wiadomosci:
* o terminach powiadamia modul spotkan (dzwonek zawsze, e-mail tylko przy potwierdzeniu),
* wiec kazda propozycja nie ma prawa wygenerowac osobnego maila.
*/
@Transactional
public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
// 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();
@@ -246,6 +272,11 @@ public class ConversationService {
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ć.");
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pl.polskalokalnie.auth.JwtService;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
private final JwtService jwtService;
public NotificationController(NotificationService notificationService) {
public NotificationController(NotificationService notificationService, JwtService jwtService) {
this.notificationService = notificationService;
this.jwtService = jwtService;
}
@GetMapping
@@ -86,6 +89,15 @@ public class NotificationController {
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)
public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName());
@@ -8,7 +8,6 @@ import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -92,17 +91,18 @@ public class OgController {
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
return ResponseEntity.notFound().build();
}
String cover = listing.getPhotos().get(0);
DecodedImage image = decodeDataUrl(cover);
DecodedImage image = decodeDataUrl(listing.getPhotos().get(0));
if (image == null) {
// Zdjecie zapisane jako zwykly URL (nie data URL) - odsylamy crawlera pod ten adres.
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", cover)
.build();
// 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());
}
@@ -111,9 +111,14 @@ public class OgController {
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
}
private record DecodedImage(String mimeType, byte[] data) {}
record DecodedImage(String mimeType, byte[] data) {}
private static DecodedImage decodeDataUrl(String value) {
// 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;
}
@@ -129,6 +134,10 @@ public class OgController {
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);
@@ -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.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
*
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
* 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,
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
*/
@Service
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 SANDBOX_URL = "https://testpay.autopay.eu/payment";
/** Adres paywalla z podpisanymi parametrami transakcji. */
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail, String returnUrl) {
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
// Kolejnosc parametrow ma znaczenie dla podpisu.
Map<String, String> params = new LinkedHashMap<>();
params.put("ServiceID", cfg.getServiceId());
params.put("OrderID", orderId);
params.put("Amount", amountStr);
params.put("Currency", "PLN");
params.put("Description", description == null ? "" : description);
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
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;
/**
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
*/
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail) {
String amountStr = formatAmount(amount);
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
customerEmail == null ? "" : customerEmail));
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
customerEmail, hash);
}
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
if (params == null || cfg == null || cfg.getSecretKey() == null) {
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));
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
public static String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
public String computeHash(List<String> values, String separator, String key) {
List<String> parts = new ArrayList<>(values);
parts.add(key);
String data = String.join(separator, parts);
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
public String hash(PaymentConfig cfg, List<String> values) {
StringBuilder sb = new StringBuilder();
for (String value : values) {
if (value == null || value.isEmpty()) {
continue; // puste pole opcjonalne: pomijamy bez separatora
}
sb.append(value).append(cfg.getHashSeparator());
}
sb.append(cfg.getSecretKey());
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
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());
}
}
@@ -49,6 +49,35 @@ public class GusBdlClient {
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"));
}
@@ -1,56 +1,35 @@
package pl.polskalokalnie.promotion;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
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).
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
*
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
*/
@RestController
@RequestMapping("/api/payments/autopay")
public class AutoPayItnController {
private final AutoPayService autoPayService;
private final PaymentConfigService paymentConfigService;
private final PromotionService promotionService;
private final AutoPayItnService itnService;
public AutoPayItnController(AutoPayService autoPayService,
PaymentConfigService paymentConfigService,
PromotionService promotionService) {
this.autoPayService = autoPayService;
this.paymentConfigService = paymentConfigService;
this.promotionService = promotionService;
public AutoPayItnController(AutoPayItnService itnService) {
this.itnService = itnService;
}
@PostMapping("/itn")
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
PaymentConfig cfg = paymentConfigService.get();
if (!autoPayService.verifyItn(params, cfg)) {
return ResponseEntity.badRequest().body("ERROR: invalid hash");
}
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");
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(itnService.handle(transactions));
}
}
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
@@ -9,6 +9,7 @@ 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 pl.polskalokalnie.payment.AutoPayStartForm;
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
@RestController
@@ -51,6 +52,11 @@ public class PromotionController {
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) {
}
@@ -1,10 +1,22 @@
package pl.polskalokalnie.promotion;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
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> {
Optional<PromotionOrder> findByExternalId(String externalId);
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.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.AutoPayStartForm;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser;
@@ -27,7 +28,7 @@ public class PromotionService {
private final ListingRepository listingRepository;
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final String baseUrl;
private final boolean autoConfirmPayments;
public PromotionService(PromotionPlanRepository planRepository,
PromotionPackageRepository packageRepository,
@@ -36,7 +37,7 @@ public class PromotionService {
ListingRepository listingRepository,
PaymentConfigService paymentConfigService,
AutoPayService autoPayService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
this.planRepository = planRepository;
this.packageRepository = packageRepository;
this.orderRepository = orderRepository;
@@ -44,7 +45,7 @@ public class PromotionService {
this.listingRepository = listingRepository;
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
this.autoConfirmPayments = autoConfirmPayments;
}
// --- Publiczne (uzytkownik) ---
@@ -63,6 +64,14 @@ public class PromotionService {
@Transactional
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();
order.setUserEmail(email);
order.setKind(request.kind());
@@ -70,8 +79,7 @@ public class PromotionService {
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
if (request.kind() == PromotionKind.PLAN) {
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
order.setRefId(plan.getId());
order.setItemName("Promowanie: " + plan.getName());
@@ -79,6 +87,7 @@ public class PromotionService {
order.setListingId(listing.getId());
} else {
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
.filter(PromotionPackage::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
order.setRefId(pack.getId());
order.setItemName("Pakiet promowan: " + pack.getName());
@@ -86,16 +95,43 @@ public class PromotionService {
}
order = orderRepository.save(order);
PaymentConfig cfg = paymentConfigService.get();
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);
return CheckoutResponse.paid(order.getId());
}
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
order.getItemName(), email, returnUrl);
return CheckoutResponse.redirect(order.getId(), url);
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
}
/** 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
@@ -115,33 +151,24 @@ public class PromotionService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
userRepository.save(user);
userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
}
order.setStatus(OrderStatus.PAID);
order.setPaidAt(Instant.now());
orderRepository.save(order);
}
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
@Transactional
public void markPaidByExternalId(String externalId) {
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
}
@Transactional
public int promoteWithCredit(String email, CreditPromoteRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
if (user.getPromotionCredits() <= 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
PromotionPlan plan = planRepository.findById(request.planId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
user.setPromotionCredits(user.getPromotionCredits() - 1);
userRepository.save(user);
// Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
if (userRepository.consumePromotionCredit(user.getId()) == 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing);
@@ -158,7 +185,15 @@ public class PromotionService {
order.setPaidAt(Instant.now());
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) {
@@ -284,7 +319,8 @@ public class PromotionService {
return id;
}
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
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;
}
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
// zgloszonego) widzi tylko administracja.
@PostMapping("/listings")
public ListingReportResponse createListingReport(
public ReportReceiptResponse createListingReport(
@Valid @RequestBody CreateListingReportRequest request,
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.
@PostMapping("/users")
public UserReportResponse createUserReport(
public ReportReceiptResponse createUserReport(
@Valid @RequestBody CreateUserReportRequest request,
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.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@@ -30,7 +31,10 @@ public class ListingReportService {
@Transactional
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())
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
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,7 @@ 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.ListingService;
import pl.polskalokalnie.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.Message;
@@ -20,19 +21,21 @@ public class UserReportService {
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
private static final int MAX_CONVERSATION_MESSAGES = 50;
private static final int MAX_MESSAGE_LENGTH = 2000;
private final UserReportRepository userReportRepository;
private final UserRepository userRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final ListingService listingService;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
ConversationRepository conversationRepository, MessageRepository messageRepository) {
ConversationRepository conversationRepository, MessageRepository messageRepository,
ListingService listingService) {
this.userReportRepository = userReportRepository;
this.userRepository = userRepository;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.listingService = listingService;
}
@Transactional
@@ -42,7 +45,6 @@ public class UserReportService {
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
report.setListingId(request.listingId());
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
report.setConversation(cleanConversation(request.conversation()));
report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(ReportAttachments.trimToNull(request.details()));
@@ -53,7 +55,8 @@ public class UserReportService {
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje.
// 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;
@@ -103,6 +106,7 @@ public class UserReportService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(reported.getEmail());
userRepository.delete(reported);
report.setReportedUserDeleted(true);
@@ -115,34 +119,6 @@ public class UserReportService {
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
}
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
if (payloads == null || payloads.isEmpty()) {
return List.of();
}
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
: payloads;
List<UserReportMessage> cleaned = new ArrayList<>();
for (UserReportMessagePayload payload : tail) {
if (payload == null) {
continue;
}
String content = payload.content() == null ? "" : payload.content().trim();
if (content.length() > MAX_MESSAGE_LENGTH) {
content = content.substring(0, MAX_MESSAGE_LENGTH);
}
UserReportMessage message = new UserReportMessage();
message.setFromReporter(payload.fromReporter());
message.setContent(content);
message.setSentAt(payload.sentAt());
message.setHasPhoto(payload.hasPhoto());
cleaned.add(message);
}
return cleaned;
}
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
private Conversation reporterConversation(String conversationId, AppUser reporter) {
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
@@ -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);
}
@@ -23,8 +23,11 @@ public class MessageComposer {
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
public String composeEmail(String rawBody, String recipientName, Long leadId) {
String personalized = personalize(rawBody, recipientName);
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
// O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
// 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 footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
@@ -64,6 +67,7 @@ public class MessageComposer {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;");
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
import org.springframework.http.MediaType;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.HtmlUtils;
/**
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
@@ -20,17 +22,40 @@ public class UnsubscribeController {
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)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
return page("Rezygnacja przyjęta",
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
public String confirm(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.preview(token);
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
+ "<button type=\"submit\">Wypisz mnie</button></form>";
return page("Rezygnacja z wiadomości",
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
}
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\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + title + "</title>"
@@ -38,7 +63,10 @@ public class UnsubscribeController {
+ "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;"
+ "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>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
+ "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);
}
/** 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
public Result unsubscribe(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);
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"));
Target target = parse(token);
Lead lead = target.lead();
String channel = target.channel();
if ("sms".equals(channel)) {
lead.setSmsOptOut(true);
@@ -76,6 +65,38 @@ public class UnsubscribeService {
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) {
try {
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.Table;
import java.time.Instant;
import java.time.LocalDate;
import org.hibernate.annotations.ColumnDefault;
@Entity
@@ -52,6 +51,9 @@ public class AppUser {
@Column(length = 30)
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)
private String address;
@@ -68,8 +70,6 @@ public class AppUser {
@Column(length = 20)
private String nip;
private LocalDate birthDate;
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
@Column(nullable = false)
@ColumnDefault("0")
@@ -95,10 +95,21 @@ public class AppUser {
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
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
void onCreate() {
if (createdAt == null) {
@@ -209,14 +220,6 @@ public class AppUser {
this.nip = nip;
}
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
public int getPromotionCredits() {
return promotionCredits;
}
@@ -261,6 +264,23 @@ public class AppUser {
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;
}
@@ -2,6 +2,9 @@ package pl.polskalokalnie.user;
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;
public interface UserRepository extends JpaRepository<AppUser, Long> {
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findFirstByRole(Role role);
long countByRole(Role role);
/**
* Zuzycie jednego kredytu promowania jednym zapytaniem z warunkiem - dwa rownolegle zadania
* nie wydadza tego samego kredytu dwa razy. Zwraca 0, gdy kredytow juz nie ma.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update AppUser u set u.promotionCredits = u.promotionCredits - 1 where u.id = :id and u.promotionCredits > 0")
int consumePromotionCredit(@Param("id") Long id);
/** Dopisanie kredytow bez odczytu i zapisu calego konta (nie nadpisze rownoleglego zuzycia). */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update AppUser u set u.promotionCredits = u.promotionCredits + :amount where u.id = :id")
int addPromotionCredits(@Param("id") Long id, @Param("amount") int amount);
}
@@ -110,9 +110,13 @@ public class UserSettings {
@ColumnDefault("true")
private boolean notifyMessages = true;
@Column(nullable = false)
@ColumnDefault("false")
private boolean notifyProductNews = false;
/**
* Lokalizacje ostatnio sprawdzone w Historii cen (JSON, maks. kilka pozycji). Lista jest
* na koncie, a nie w przegladarce, wiec porownanie cen wyglada tak samo na kazdym urzadzeniu.
* To zwykla historia przegladania - nie ma z nia zwiazanych zadnych powiadomien.
*/
@Column(name = "price_history_markets", columnDefinition = "text")
private String priceHistoryMarkets;
// --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) ---
@@ -302,18 +306,18 @@ public class UserSettings {
this.notifyMessages = notifyMessages;
}
public boolean isNotifyProductNews() {
return notifyProductNews;
}
public void setNotifyProductNews(boolean notifyProductNews) {
this.notifyProductNews = notifyProductNews;
}
public String getSearchLocations() {
return searchLocations;
}
public String getPriceHistoryMarkets() {
return priceHistoryMarkets;
}
public void setPriceHistoryMarkets(String priceHistoryMarkets) {
this.priceHistoryMarkets = priceHistoryMarkets;
}
public void setSearchLocations(String searchLocations) {
this.searchLocations = searchLocations;
}
@@ -24,7 +24,7 @@ public record UserSettingsRequest(
Boolean notifySavedSearches,
Boolean notifyPriceAlerts,
Boolean notifyMessages,
Boolean notifyProductNews,
String priceHistoryMarkets,
String searchLocations,
String searchPropertyType,
Integer searchBudgetMax,
@@ -21,7 +21,7 @@ public record UserSettingsResponse(
boolean notifySavedSearches,
boolean notifyPriceAlerts,
boolean notifyMessages,
boolean notifyProductNews,
String priceHistoryMarkets,
String searchLocations,
String searchPropertyType,
Integer searchBudgetMax,
@@ -51,7 +51,7 @@ public record UserSettingsResponse(
settings.isNotifySavedSearches(),
settings.isNotifyPriceAlerts(),
settings.isNotifyMessages(),
settings.isNotifyProductNews(),
settings.getPriceHistoryMarkets(),
settings.getSearchLocations(),
settings.getSearchPropertyType(),
settings.getSearchBudgetMax(),
@@ -132,8 +132,9 @@ public class UserSettingsService {
if (request.notifyMessages() != null) {
settings.setNotifyMessages(request.notifyMessages());
}
if (request.notifyProductNews() != null) {
settings.setNotifyProductNews(request.notifyProductNews());
if (request.priceHistoryMarkets() != null) {
String markets = request.priceHistoryMarkets().trim();
settings.setPriceHistoryMarkets(markets.isEmpty() ? null : markets);
}
if (request.searchLocations() != null) {
String locations = request.searchLocations().trim();
@@ -25,6 +25,10 @@ app:
# W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa.
secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min}
expiration-seconds: ${APP_JWT_EXPIRATION_SECONDS:86400}
payments:
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest od razu uznawany za oplacony.
# Tylko do pracy lokalnej - na produkcji oznaczaloby darmowe promowanie dla kazdego.
auto-confirm: ${APP_PAYMENTS_AUTO_CONFIRM:false}
admin:
email: ${APP_ADMIN_EMAIL:admin@softspm.com}
password: ${APP_ADMIN_PASSWORD:Admin123!}
@@ -0,0 +1,80 @@
package pl.polskalokalnie;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/** Publiczne endpointy - regresje z audytu bezpieczenstwa 2026-09-23. */
@SpringBootTest
@AutoConfigureMockMvc
class PublicEndpointsSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private LeadRepository leadRepository;
@Autowired
private UnsubscribeService unsubscribeService;
@Test
void privateListingListsRequireLogin() throws Exception {
// Regresja: publiczna regula GET /api/listings/** przepuszczala je anonimowo (blad 500).
mockMvc.perform(get("/api/listings/favorites")).andExpect(status().isForbidden());
mockMvc.perform(get("/api/listings/mine/saves")).andExpect(status().isForbidden());
}
@Test
void phoneCodeResendDoesNotRevealWhetherAccountExists() throws Exception {
// Regresja: nieznany adres dawal 404 "Uzytkownik nie istnieje".
mockMvc.perform(post("/api/auth/resend-phone-otp")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nikogo-takiego@example.com\"}"))
.andExpect(status().isNoContent());
}
@Test
void registrationRequiresEightCharacterPassword() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nowy@example.com\",\"password\":\"abc123\",\"fullName\":\"Jan Nowak\"}"))
.andExpect(status().isBadRequest());
}
@Test
void openingUnsubscribeLinkOnlyAsksForConfirmation() throws Exception {
// Regresja: sam GET (np. skaner linkow w poczcie) wypisywal z mailingu.
Lead lead = new Lead();
lead.setEmail("subskrybent@example.com");
lead.setEmailConsent(true);
lead = leadRepository.save(lead);
String token = unsubscribeService.generateToken(lead.getId(), "email");
mockMvc.perform(get("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("method=\"post\"")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isFalse();
mockMvc.perform(post("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Rezygnacja przyjęta")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isTrue();
}
@Test
void tamperedUnsubscribeTokenIsRejected() throws Exception {
mockMvc.perform(post("/api/unsubscribe").param("token", "MTpbZW1haWw.nieprawidlowy-podpis"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,22 @@
package pl.polskalokalnie.auth;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class AccountEmailServiceTest {
@Test
void greetingEscapesNameFromRegistrationForm() {
// Regresja: rejestracja na cudzy e-mail z linkiem w polu imienia dawala prawdziwy mail
// aktywacyjny z serwisu z linkiem atakujacego.
String html = AccountEmailService.greeting("<a/href=https://evil.example>Kliknij</a>");
assertThat(html).doesNotContain("<a").contains("&lt;a/href=");
}
@Test
void greetingUsesFirstName() {
assertThat(AccountEmailService.greeting("Jan Kowalski")).isEqualTo("<p>Cześć Jan,</p>");
}
}
@@ -0,0 +1,103 @@
package pl.polskalokalnie.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
class JwtAuthFilterTest {
private AppUser user() {
AppUser user = new AppUser();
user.setEmail("user@example.com");
user.setRole(Role.USER);
return user;
}
private final JwtService jwtService = new JwtService("test-secret-key-for-junit-context-32chars-min", 3600);
private final UserRepository userRepository = mock(UserRepository.class);
private final JwtAuthFilter filter = new JwtAuthFilter(jwtService, userRepository);
/** Czy zadanie przeszlo przez filtr jako uwierzytelnione. */
private boolean authenticated(MockHttpServletRequest request) throws Exception {
AppUser user = user();
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
boolean[] result = new boolean[1];
filter.doFilter(request, new MockHttpServletResponse(),
(req, res) -> result[0] = SecurityContextHolder.getContext().getAuthentication() != null);
SecurityContextHolder.clearContext();
return result[0];
}
private MockHttpServletRequest get(String uri) {
return new MockHttpServletRequest("GET", uri);
}
@Test
void sessionTokenWorksOnlyInHeader() throws Exception {
String session = jwtService.generateToken(user());
MockHttpServletRequest header = get("/api/auth/me");
header.addHeader("Authorization", "Bearer " + session);
assertThat(authenticated(header)).isTrue();
// Regresja: token sesji w adresie (?access_token=) trafial do logow serwera.
MockHttpServletRequest inUrl = get("/api/auth/me");
inUrl.setParameter("access_token", session);
assertThat(authenticated(inUrl)).isFalse();
MockHttpServletRequest asTicket = get(JwtAuthFilter.STREAM_PATH);
asTicket.setParameter("ticket", session);
assertThat(authenticated(asTicket)).isFalse();
}
@Test
void streamTokenWorksOnlyForNotificationStream() throws Exception {
String ticket = jwtService.generateStreamToken("user@example.com");
MockHttpServletRequest stream = get(JwtAuthFilter.STREAM_PATH);
stream.setParameter("ticket", ticket);
assertThat(authenticated(stream)).isTrue();
MockHttpServletRequest otherPath = get("/api/auth/me");
otherPath.setParameter("ticket", ticket);
assertThat(authenticated(otherPath)).isFalse();
MockHttpServletRequest asSession = get("/api/auth/me");
asSession.addHeader("Authorization", "Bearer " + ticket);
assertThat(authenticated(asSession)).isFalse();
}
@Test
void activeUserWithFreshTokenIsAccepted() {
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
}
@Test
void blockedUserLosesAccessImmediately() {
// Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h.
AppUser user = user();
user.setBlocked(true);
assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse();
}
@Test
void tokenIssuedBeforePasswordChangeIsRejected() {
AppUser user = user();
Instant oldToken = Instant.now().minusSeconds(60);
user.revokeSessions();
assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse();
// Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny.
assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue();
}
}
@@ -0,0 +1,140 @@
package pl.polskalokalnie.campaign;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Limit;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
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;
class CampaignSenderTest {
private CampaignRepository campaignRepository;
private CampaignRecipientRepository recipientRepository;
private LeadRepository leadRepository;
private EmailSender emailSender;
private CampaignSender sender;
private Campaign campaign;
@BeforeEach
void setUp() {
campaignRepository = mock(CampaignRepository.class);
recipientRepository = mock(CampaignRecipientRepository.class);
leadRepository = mock(LeadRepository.class);
emailSender = mock(EmailSender.class);
MailConfigService mailConfigService = mock(MailConfigService.class);
UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
SmtpConfig smtp = new SmtpConfig();
smtp.setEnabled(true);
smtp.setHost("smtp.example.com");
when(mailConfigService.getSmtp()).thenReturn(smtp);
when(unsubscribeService.generateToken(any(), anyString())).thenReturn("token");
when(emailSender.send(any(), anyString(), any(), anyString())).thenReturn(new SendResult(true, null, null));
campaign = new Campaign();
campaign.setChannel(Channel.EMAIL);
campaign.setSubject("Nowe oferty");
campaign.setBody("<p>Treść</p>");
campaign.setDailyLimit(200);
campaign.setStatus(CampaignStatus.SENDING);
when(campaignRepository.findByStatus(CampaignStatus.SCHEDULED)).thenReturn(List.of());
when(campaignRepository.findByStatus(CampaignStatus.SENDING)).thenReturn(List.of(campaign));
sender = new CampaignSender(campaignRepository, recipientRepository, leadRepository, mailConfigService,
emailSender, mock(SmsSender.class), unsubscribeService, "https://polskalokalnie.pl");
}
private CampaignRecipient recipient(long leadId, String address) {
CampaignRecipient recipient = new CampaignRecipient();
recipient.setLeadId(leadId);
recipient.setChannel(Channel.EMAIL);
recipient.setAddress(address);
recipient.setStatus(RecipientStatus.QUEUED);
return recipient;
}
private Lead lead(boolean consent, boolean optOut) {
Lead lead = new Lead();
lead.setEmailConsent(consent);
lead.setEmailOptOut(optOut);
return lead;
}
private void queue(List<CampaignRecipient> batch) {
when(recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class)))
.thenReturn(batch, List.of());
}
@Test
void sendsQueuedRecipientAndClosesCampaign() {
CampaignRecipient recipient = recipient(1L, "kupujacy@example.com");
queue(List.of(recipient));
when(leadRepository.findById(1L)).thenReturn(Optional.of(lead(true, false)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.SENT);
assertThat(recipient.getSentAt()).isNotNull();
// Pusta kolejka po przebiegu = kampania rozliczona.
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.COMPLETED);
assertThat(campaign.getSentToday()).isEqualTo(1);
}
@Test
void skipsRecipientWhoWithdrewConsentAfterBuildingOutbox() {
CampaignRecipient recipient = recipient(2L, "rezygnacja@example.com");
queue(List.of(recipient));
when(leadRepository.findById(2L)).thenReturn(Optional.of(lead(true, true)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.OPTED_OUT);
verify(emailSender, never()).send(any(), anyString(), any(), anyString());
}
@Test
void marksFailureWithoutLosingTheRest() {
CampaignRecipient recipient = recipient(3L, "blad@example.com");
queue(List.of(recipient));
when(leadRepository.findById(3L)).thenReturn(Optional.of(lead(true, false)));
when(emailSender.send(any(), anyString(), any(), anyString()))
.thenReturn(new SendResult(false, null, "Nie udało się połączyć"));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.FAILED);
assertThat(recipient.getError()).contains("połączyć");
assertThat(campaign.getSentToday()).isZero();
}
@Test
void waitsWhenDailyLimitIsUsedUp() {
campaign.setDailyLimit(1);
campaign.setSentToday(1);
campaign.setSentTodayDate(java.time.LocalDate.now());
sender.processOutbox();
verify(recipientRepository, never())
.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class));
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.SENDING);
}
}
@@ -0,0 +1,54 @@
package pl.polskalokalnie.lead;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class AdvertisingEnquiryConsentTest {
private final LeadRepository repository = mock(LeadRepository.class);
private final LeadService service = new LeadService(repository);
private AdvertisingEnquiryRequest enquiry(String name) {
return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700",
"Remonty", null, true);
}
@Test
void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() {
// Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe.
Lead existing = new Lead();
ReflectionTestUtils.setField(existing, "id", 5L);
existing.setEmail("ofiara@example.com");
existing.setName("Prawdziwe Nazwisko");
existing.setEmailConsent(false);
existing.setSmsConsent(false);
when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("<a href=https://evil>Kliknij</a>"));
assertThat(existing.isEmailConsent()).isFalse();
assertThat(existing.isSmsConsent()).isFalse();
assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko");
}
@Test
void newContactGetsConsentFromForm() {
when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty());
when(repository.findFirstByPhone(any())).thenReturn(Optional.empty());
Lead[] saved = new Lead[1];
when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("Jan Kowalski"));
assertThat(saved[0].isEmailConsent()).isTrue();
assertThat(saved[0].isSmsConsent()).isTrue();
assertThat(saved[0].getName()).isEqualTo("Jan Kowalski");
}
}
@@ -0,0 +1,71 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.search.SavedSearchNotifier;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
class ListingEditModerationTest {
private static final String OWNER = "wlasciciel@example.com";
private static final String PHOTO = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
private final ListingRepository repository = mock(ListingRepository.class);
private final ListingService service = new ListingService(repository, mock(TextModerationService.class),
mock(NotificationService.class), mock(UserRepository.class), mock(UserSettingsService.class),
mock(SavedSearchNotifier.class));
private PropertyListing listing;
private ListingCreateRequest request(String title, BigDecimal price, String phone) {
return new ListingCreateRequest(title, "Opis mieszkania", OfferType.SALE, PropertyType.APARTMENT, "Kraków",
null, price, 50.0, 2, null, null, null, null, null, null, null, null, null,
"Jan", phone, OWNER, null, null, null, null, null, null, null, null,
null, null, List.of(), List.of(), List.of(PHOTO), null, false);
}
@BeforeEach
void setUp() {
listing = new PropertyListing();
listing.setOwnerEmail(OWNER);
when(repository.findById(1L)).thenReturn(Optional.of(listing));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// Stan po pierwszym zapisie i akceptacji przez administracje.
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "500600700"));
listing.setStatus(ListingStatus.APPROVED);
}
@Test
void priceChangeStaysPublished() {
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("480000"), "500600700"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.APPROVED);
}
@Test
void changedContentGoesBackToModeration() {
// Regresja: zatwierdzone ogloszenie mozna bylo podmienic na dowolna tresc bez weryfikacji.
service.updateOwn(1L, OWNER, request("Okazja! Zadzwon teraz", new BigDecimal("500000"), "500600700"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
}
@Test
void changedPhoneOfPausedListingGoesBackToModeration() {
listing.setStatus(ListingStatus.PAUSED);
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "999888777"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
}
}
@@ -0,0 +1,56 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingPrivacyTest {
private PropertyListing listing() {
PropertyListing listing = new PropertyListing();
listing.setOwnerEmail("wlasciciel@example.com");
listing.setContactEmail("wlasciciel@example.com");
listing.setStatus(ListingStatus.APPROVED);
return listing;
}
@Test
void approximateLocationStaysInsideCircleAndDoesNotDependOnListingId() {
// Regresja: przesuniecie liczone z jawnego id dalo sie odwrocic i odzyskac dokladny adres.
PropertyListing a = listing();
a.setLat(52.229676);
a.setLng(21.012229);
PropertyListing b = listing();
b.setLat(52.229700);
b.setLng(21.012300);
double lat = ListingDetailResponse.blurLat(a);
double lng = ListingDetailResponse.blurLng(a);
// Dwa rozne adresy w tej samej kratce daja ten sam punkt - nie da sie z niego wrocic do adresu.
assertThat(ListingDetailResponse.blurLat(b)).isEqualTo(lat);
assertThat(ListingDetailResponse.blurLng(b)).isEqualTo(lng);
double dLatM = (lat - a.getLat()) * 111_320d;
double dLngM = (lng - a.getLng()) * 111_320d * Math.cos(Math.toRadians(a.getLat()));
assertThat(Math.hypot(dLatM, dLngM)).isLessThan(300d);
}
@Test
void publicListDoesNotExposeOwnerEmail() {
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull();
assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com");
}
@Test
void publicDetailHidesAccountEmailButOwnerSeesIt() {
ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true);
assertThat(publicView.ownerEmail()).isNull();
assertThat(publicView.contactEmail()).isNull();
assertThat(publicView.ownerId()).isEqualTo(7L);
ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false);
assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com");
assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com");
}
}
@@ -0,0 +1,33 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
class ListingServiceTest {
@Test
void acceptsRasterImageDataUrls() {
List<String> photos = List.of(
"data:image/jpeg;base64,/9j/4AAQSkZJRg==",
"data:image/png;base64,iVBORw0KGgo=",
"data:image/webp;base64,UklGRg==");
assertThat(ListingService.limitPhotos(photos)).containsExactlyElementsOf(photos);
}
@Test
void rejectsPhotosThatCouldRunScriptOnSiteOrigin() {
// Regresja: data URL z text/html lub SVG serwowany z /api/og/... uruchamial skrypt w domenie serwisu.
for (String photo : List.of(
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=",
"https://evil.example/redirect",
"javascript:alert(1)")) {
assertThatThrownBy(() -> ListingService.limitPhotos(List.of(photo)))
.isInstanceOf(ResponseStatusException.class);
}
}
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingViewGuardTest {
@Test
void countsOneViewPerVisitorAndListing() {
// Regresja: kazde odswiezenie strony podbijalo licznik wyswietlen.
ListingViewGuard guard = new ListingViewGuard();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isTrue();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isFalse();
assertThat(guard.shouldCount(1L, "203.0.113.2")).isTrue();
assertThat(guard.shouldCount(2L, "203.0.113.1")).isTrue();
}
}
@@ -0,0 +1,53 @@
package pl.polskalokalnie.market;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie odpowiedzi JSON-stat z Eurostatu - ksztalt jak w zbiorze irt_st_m. */
class EurostatRateClientTest {
private final ObjectMapper mapper = new ObjectMapper();
private String response(String values, String periods) {
return "{\"value\":" + values + ",\"dimension\":{\"time\":{\"category\":{\"index\":" + periods + "}}}}";
}
@Test
void bierzeNajpozniejszyMiesiacZWartoscia() throws Exception {
String json = response("{\"0\":3.85,\"1\":3.82,\"2\":3.83}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-08");
assertThat(rate.percent()).isEqualByComparingTo("3.83");
}
@Test
void pomijaMiesiaceBezWartosci() throws Exception {
// Ostatni okres bywa jeszcze pusty - wtedy bierzemy poprzedni, a nie zglaszamy bledu.
String json = response("{\"0\":3.85,\"1\":3.82}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-07");
assertThat(rate.percent()).isEqualByComparingTo("3.82");
}
@Test
void odrzucaOdpowiedzBezDanych() throws Exception {
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree("{}")))
.isInstanceOf(IOException.class);
assertThatThrownBy(() -> EurostatRateClient.parseLatest(
mapper.readTree(response("{}", "{\"2026-08\":0}"))))
.isInstanceOf(IOException.class);
}
@Test
void odrzucaNieprawidlowaStope() throws Exception {
String json = response("{\"0\":0}", "{\"2026-08\":0}");
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree(json)))
.isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,48 @@
package pl.polskalokalnie.message;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
class ConversationStartTest {
@Test
void cannotStartConversationAboutUnpublishedListing() {
// Regresja: rozmowa o ogloszeniu oczekujacym/odrzuconym ujawniala jego tytul, cene i sprzedajacego.
ConversationRepository conversations = mock(ConversationRepository.class);
ListingRepository listings = mock(ListingRepository.class);
ConversationService service = new ConversationService(conversations, mock(MessageRepository.class),
mock(UserBlockRepository.class), mock(UserRepository.class), listings,
mock(TextModerationService.class), mock(ApplicationEventPublisher.class));
PropertyListing pending = new PropertyListing();
pending.setStatus(ListingStatus.PENDING);
pending.setOwnerEmail("sprzedajacy@example.com");
when(listings.findById(5L)).thenReturn(Optional.of(pending));
AppUser me = new AppUser();
ReflectionTestUtils.setField(me, "id", 1L);
when(conversations.findByListingIdAndBuyerId(5L, 1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.start(me, 5L, "Dzień dobry"))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(conversations, never()).save(any());
}
}
@@ -0,0 +1,23 @@
package pl.polskalokalnie.og;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class OgControllerTest {
@Test
void decodesRasterImage() {
OgController.DecodedImage image = OgController.decodeDataUrl("data:image/png;base64,iVBORw0KGgo=");
assertThat(image).isNotNull();
assertThat(image.mimeType()).isEqualTo("image/png");
}
@Test
void refusesMimeTypesThatBrowsersExecute() {
// Regresja: typ MIME z data URL trafial wprost do Content-Type odpowiedzi.
assertThat(OgController.decodeDataUrl("data:text/html;base64,PHNjcmlwdD48L3NjcmlwdD4=")).isNull();
assertThat(OgController.decodeDataUrl("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=")).isNull();
assertThat(OgController.decodeDataUrl("https://evil.example/")).isNull();
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
* jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
*/
class PriceAlertThresholdTest {
private boolean notifiesLocation(long previous, long current) {
double changePct = ((current - previous) * 100.0) / previous;
return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
}
@Test
void ignoresSmallMoveOfTheAverage() {
// 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
assertThat(notifiesLocation(14_000, 14_200)).isFalse();
assertThat(notifiesLocation(14_000, 13_800)).isFalse();
}
@Test
void notifiesAboutRealMove() {
assertThat(notifiesLocation(14_000, 14_280)).isTrue();
assertThat(notifiesLocation(14_659, 13_523)).isTrue();
}
}
@@ -0,0 +1,37 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie rocznych wartosci z BDL - przecietne wynagrodzenie brutto dla calej Polski. */
class GusWageParsingTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void bierzeNajnowszyRok() throws Exception {
String json = "{\"results\":[{\"id\":\"000000000000\",\"name\":\"POLSKA\",\"values\":["
+ "{\"year\":\"2023\",\"val\":7595.30},{\"year\":\"2025\",\"val\":9371.11},"
+ "{\"year\":\"2024\",\"val\":8630.27}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2025);
assertThat(wage.value()).isEqualByComparingTo("9371.11");
}
@Test
void pomijaLataBezWartosci() throws Exception {
String json = "{\"results\":[{\"values\":[{\"year\":\"2024\",\"val\":8630.27},{\"year\":\"2025\",\"val\":0}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2024);
}
@Test
void odrzucaPustaOdpowiedz() {
assertThatThrownBy(() -> GusBdlClient.parseLatestAnnualValue(mapper.readTree("{\"results\":[]}")))
.isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,205 @@
package pl.polskalokalnie.promotion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Zabezpieczenia obslugi notyfikacji ITN (scenariusze przeniesione z Gieldy Komunalnej):
* podszycie sie pod bramke, zanizenie kwoty, podmiana waluty, statusy nie-koncowe i powtorki.
*/
class AutoPayItnServiceTest {
private static final String SERVICE_ID = "218886";
private static final String KEY = "sekretny-klucz-hash";
private static final String ORDER_ID = "abc123";
private final AutoPayService autoPayService = new AutoPayService();
private PromotionOrderRepository orderRepository;
private PromotionService promotionService;
private AutoPayItnService service;
private PaymentConfig cfg;
private PromotionOrder order;
@BeforeEach
void setUp() {
orderRepository = mock(PromotionOrderRepository.class);
promotionService = mock(PromotionService.class);
PaymentConfigService configService = mock(PaymentConfigService.class);
cfg = new PaymentConfig();
cfg.setServiceId(SERVICE_ID);
cfg.setSecretKey(KEY);
cfg.setEnabled(true);
when(configService.get()).thenReturn(cfg);
service = new AutoPayItnService(configService, autoPayService, orderRepository, promotionService);
order = new PromotionOrder();
order.setExternalId(ORDER_ID);
order.setAmount(new BigDecimal("49.99"));
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
}
private String itn(String amount, String currency, String status) {
String hash = autoPayService.hash(cfg, List.of(
SERVICE_ID, ORDER_ID, "remote-1", amount, currency, "21", "20260827120000", status, "OK"));
return encode(xml(amount, currency, status, hash));
}
private String xml(String amount, String currency, String status, String hash) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<transactionList>"
+ "<serviceID>" + SERVICE_ID + "</serviceID>"
+ "<transactions><transaction>"
+ "<orderID>" + ORDER_ID + "</orderID>"
+ "<remoteID>remote-1</remoteID>"
+ "<amount>" + amount + "</amount>"
+ "<currency>" + currency + "</currency>"
+ "<gatewayID>21</gatewayID>"
+ "<paymentDate>20260827120000</paymentDate>"
+ "<paymentStatus>" + status + "</paymentStatus>"
+ "<paymentStatusDetails>OK</paymentStatusDetails>"
+ "</transaction></transactions>"
+ "<hash>" + hash + "</hash>"
+ "</transactionList>";
}
private static String encode(String xml) {
return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8));
}
private static String sha256(String input) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : d) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
@Test
void hashFollowsDocumentationVector() throws Exception {
PaymentConfig doc = new PaymentConfig();
doc.setSecretKey("2test2");
assertThat(autoPayService.hash(doc, List.of("2", "100", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
// Puste pole opcjonalne pomijane razem z separatorem.
assertThat(autoPayService.hash(doc, List.of("2", "100", "", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
}
@Test
void confirmsValidSuccessNotification() {
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService).applyPaid(order);
}
@Test
void rejectsForgedSignature() {
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", "0000000000000000")));
assertThat(response).contains("<confirmation>NOTCONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsSignatureFromDifferentKey() {
PaymentConfig attacker = new PaymentConfig();
attacker.setSecretKey("klucz-atakujacego");
String hash = autoPayService.hash(attacker, List.of(
SERVICE_ID, ORDER_ID, "remote-1", "49.99", "PLN", "21", "20260827120000", "SUCCESS", "OK"));
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", hash)));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void pendingPaymentIsAcknowledgedButNotPaid() {
// Regresja: PENDING byl traktowany jak oplacona platnosc.
String response = service.handle(itn("49.99", "PLN", "PENDING"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void failedPaymentMarksOrderFailed() {
service.handle(itn("49.99", "PLN", "FAILURE"));
assertThat(order.getStatus()).isEqualTo(OrderStatus.FAILED);
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsUnderpayment() {
String response = service.handle(itn("0.01", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsWrongCurrency() {
String response = service.handle(itn("49.99", "EUR", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void isIdempotentOnRetry() {
service.handle(itn("49.99", "PLN", "SUCCESS"));
order.setStatus(OrderStatus.PAID); // applyPaid jest zamockowane - odwzorowujemy skutek
String second = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(second).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, times(1)).applyPaid(order);
}
@Test
void rejectsUnknownOrderId() {
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.empty());
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsNotificationsWhenGatewayIsNotConfigured() {
String valid = itn("49.99", "PLN", "SUCCESS");
cfg.setEnabled(false);
assertThat(service.handle(valid)).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void refusesXmlWithExternalEntities() {
String xxe = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///etc/passwd\">]>"
+ "<transactionList><serviceID>&e;</serviceID></transactionList>";
assertThatThrownBy(() -> service.handle(encode(xxe))).isInstanceOf(ResponseStatusException.class);
}
}
@@ -0,0 +1,123 @@
package pl.polskalokalnie.promotion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
class PaymentSecurityTest {
private final PromotionPackageRepository packageRepository = mock(PromotionPackageRepository.class);
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
private final UserRepository userRepository = mock(UserRepository.class);
private final PaymentConfigService paymentConfigService = mock(PaymentConfigService.class);
private final PromotionPlanRepository planRepository = mock(PromotionPlanRepository.class);
private final ListingRepository listingRepository = mock(ListingRepository.class);
private PromotionService service(boolean autoConfirm) {
return new PromotionService(planRepository, packageRepository, orderRepository,
userRepository, listingRepository, paymentConfigService, mock(AutoPayService.class),
autoConfirm);
}
private PropertyListing ownListing() {
PropertyListing listing = new PropertyListing();
listing.setOwnerEmail("user@example.com");
when(listingRepository.findById(10L)).thenReturn(Optional.of(listing));
return listing;
}
private PromotionPlan plan(boolean active) {
PromotionPlan plan = new PromotionPlan();
plan.setName("Plan");
plan.setDurationDays(30);
plan.setPrice(new BigDecimal("89.99"));
plan.setActive(active);
when(planRepository.findById(3L)).thenReturn(Optional.of(plan));
return plan;
}
@Test
void creditIsNotSpentTwice() {
// Regresja: odczyt-sprawdzenie-zapis salda pozwalal rownoleglym zadaniom wydac 1 kredyt kilka razy.
// Teraz o wydaniu decyduje warunkowy UPDATE w bazie - gdy nie zmieni wiersza, promowania nie ma.
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
ownListing();
plan(true);
when(userRepository.consumePromotionCredit(any())).thenReturn(0);
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
verify(listingRepository, never()).save(any());
}
@Test
void inactivePlanCannotBeBoughtOrPaidWithCredit() {
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
ownListing();
plan(false);
when(userRepository.consumePromotionCredit(any())).thenReturn(1);
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(userRepository, never()).consumePromotionCredit(any());
}
@Test
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
// Regresja: bez skonfigurowanej bramki zamowienie bylo od razu oplacone - darmowe kredyty dla kazdego.
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
assertThatThrownBy(() -> service(false).checkout("user@example.com",
new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay")))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE));
verify(orderRepository, never()).save(any());
verify(userRepository, never()).save(any());
}
@Test
void checkoutWithoutGatewayConfirmsOnlyWhenAutoConfirmIsEnabled() {
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
PromotionPackage pack = new PromotionPackage();
pack.setName("Pakiet");
pack.setQuantity(5);
pack.setPrice(new BigDecimal("199.99"));
when(packageRepository.findById(any())).thenReturn(Optional.of(pack));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AppUser user = new AppUser();
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
service(true).checkout("user@example.com", new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay"));
verify(userRepository).addPromotionCredits(user.getId(), 5);
}
@Test
void paymentDescriptionUsesOnlyCharactersAllowedByGateway() {
PromotionOrder order = new PromotionOrder();
order.setKind(PromotionKind.PLAN);
order.setListingId(42L);
assertThat(PromotionService.paymentDescription(order))
.isEqualTo("Promowanie ogloszenia nr 42")
.matches("[A-Za-z0-9 .:,-]{1,79}");
}
}
@@ -0,0 +1,38 @@
package pl.polskalokalnie.search;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.search.SavedSearchController.SavedSearchRequest;
class SavedSearchControllerTest {
private SavedSearch saved(String city, Long priceMax, String more) {
SavedSearch saved = new SavedSearch();
saved.setMode("buy");
saved.setCity(city);
saved.setPropertyType("Mieszkanie");
saved.setPriceMax(priceMax);
saved.setSecondaryMarket(true);
saved.setMoreFilters(more);
return saved;
}
private SavedSearchRequest request(String city, Long priceMax, String more) {
return new SavedSearchRequest("buy", city, "Mieszkanie", null, priceMax, null, null, null, true, false, false, more);
}
@Test
void treatsSameCriteriaAsDuplicate() {
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{}"))).isTrue();
// Spacje i brak wartosci nie robia z wyszukiwania nowego wpisu.
assertThat(SavedSearchController.sameCriteria(saved(" Kraków ", 800000L, null), request("Kraków", 800000L, ""))).isTrue();
}
@Test
void differentFiltersAreSeparateSearches() {
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 900000L, "{}"))).isFalse();
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Gdańsk", 800000L, "{}"))).isFalse();
assertThat(SavedSearchController.sameCriteria(saved("Kraków", 800000L, "{}"), request("Kraków", 800000L, "{\"balkon\":\"tak\"}"))).isFalse();
}
}
@@ -0,0 +1,120 @@
package pl.polskalokalnie.search;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
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.UserSettingsRepository;
/**
* Dopasowanie nowego ogloszenia do zapisanego wyszukiwania. Puste kryterium znaczy
* "bez ograniczenia" - nie moze odrzucac ofert.
*/
class SavedSearchNotifierTest {
private final SavedSearchNotifier notifier = new SavedSearchNotifier(
mock(SavedSearchRepository.class), mock(UserSettingsRepository.class),
mock(UserRepository.class), mock(NotificationService.class));
private PropertyListing listing() {
PropertyListing listing = new PropertyListing();
listing.setTitle("Mieszkanie 3 pokoje");
listing.setOfferType(OfferType.SALE);
listing.setPropertyType(PropertyType.APARTMENT);
listing.setCity("Gdańsk");
listing.setPrice(BigDecimal.valueOf(750_000));
listing.setArea(62.0);
listing.setRooms(3);
listing.setMarket("Wtórny");
return listing;
}
private SavedSearch search() {
SavedSearch search = new SavedSearch();
search.setMode("buy");
return search;
}
@Test
void pusteWyszukiwaniePasujeDoKazdejOfertySprzedazy() {
assertThat(notifier.matches(search(), listing())).isTrue();
}
@Test
void wyszukiwanieWynajmuNiePasujeDoSprzedazy() {
SavedSearch search = search();
search.setMode("rent");
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void miastoMusiSieZgadzac() {
SavedSearch search = search();
search.setCity("Gdańsk");
assertThat(notifier.matches(search, listing())).isTrue();
search.setCity("Kraków");
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void cenaPozaWidelkamiOdrzucaOferte() {
SavedSearch search = search();
search.setPriceMax(700_000L);
assertThat(notifier.matches(search, listing())).isFalse();
search.setPriceMax(800_000L);
assertThat(notifier.matches(search, listing())).isTrue();
search.setPriceMin(900_000L);
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void metrazIPokojeDzialajaJakoWartosciMinimalne() {
SavedSearch search = search();
search.setAreaMin(70);
assertThat(notifier.matches(search, listing())).isFalse();
search.setAreaMin(50);
search.setRooms(4);
assertThat(notifier.matches(search, listing())).isFalse();
search.setRooms(2);
assertThat(notifier.matches(search, listing())).isTrue();
}
@Test
void rynekOgraniczaTylkoWtedyGdyWybranoJeden() {
SavedSearch search = search();
search.setSecondaryMarket(true);
search.setPrimaryMarket(true);
assertThat(notifier.matches(search, listing())).isTrue();
search.setSecondaryMarket(false);
assertThat(notifier.matches(search, listing())).isFalse();
search.setSecondaryMarket(true);
search.setPrimaryMarket(false);
assertThat(notifier.matches(search, listing())).isTrue();
}
@Test
void rodzajNieruchomosciSpozaOfertySerwisuNigdyNiePasuje() {
SavedSearch search = search();
search.setPropertyType("Działka");
assertThat(notifier.matches(search, listing())).isFalse();
search.setPropertyType("Mieszkanie");
assertThat(notifier.matches(search, listing())).isTrue();
search.setPropertyType("Dom");
assertThat(notifier.matches(search, listing())).isFalse();
}
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.send;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
class MessageComposerTest {
private final UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
private final MessageComposer composer = new MessageComposer(unsubscribeService, "https://polskalokalnie.pl");
@Test
void nameInHtmlCampaignIsEscaped() {
// Regresja: imie z publicznego formularza trafialo do maila kampanii jako HTML.
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
String html = composer.composeEmail("<p>Dzień dobry {{name}}!</p>", "<a href=\"https://evil\">X</a>", 1L);
assertThat(html).doesNotContain("href=\"https://evil\"").contains("&lt;a href=&quot;https://evil&quot;&gt;");
}
@Test
void nameInPlainTextCampaignIsEscaped() {
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
String html = composer.composeEmail("Dzień dobry {{name}}!", "<b>Jan</b>", 1L);
assertThat(html).doesNotContain("<b>Jan</b>").contains("&lt;b&gt;Jan&lt;/b&gt;");
}
}
+8 -5
View File
@@ -1,12 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:polskalokalnie;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
driver-class-name: org.h2.Driver
# Testcontainers: przy pierwszym polaczeniu startuje tymczasowy PostgreSQL 16 (ta sama wersja co
# produkcja w docker-compose), wspolny dla wszystkich testow i usuwany po ich zakonczeniu.
# Wymaga uruchomionego Dockera przy "mvn test".
url: jdbc:tc:postgresql:16-alpine:///polskalokalnie
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
hibernate:
ddl-auto: create-drop
# Jak na produkcji. create-drop przy kazdym nowym kontekscie Springa kasowal tabele, z ktorych
# korzystal juz dzialajacy kontekst; baza i tak jest swieza przy kazdym uruchomieniu testow.
ddl-auto: update
app:
jwt:

Some files were not shown because too many files have changed in this diff Show More