Compare commits

54 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
debian 8cd2db38cc Menu konta takze na Wycenie i test opisujacy to zachowanie
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Wycena trafia do AccountShell, jak pozostale narzedzia: gosc widzi sama
strone, zalogowany takze lewe menu konta. Test nie zakladal juz menu
i dlatego nie przechodzil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:58:17 +02:00
debian e3cc999343 Martwe przyciski: podepnij pod prawdziwe strony albo oznacz "Wkrotce"
- AI Dopasowanie, "Zobacz kupujacych", kafelek 360 i Konto Premium:
  nieaktywne z plakietka "Wkrotce" zamiast przyciskow bez akcji.
- Pomoc: jeden adres info@polskalokalnie.pl i prawdziwy numer
  +48 453 193 605 jako linki mailto/tel, czat na zywo usuniety,
  FAQ z prawdziwymi odpowiedziami zamiast jednego zdania.
- Poradniki: dzialajaca wyszukiwarka (?szukaj= w adresie), szybkie
  poradniki linkuja do rozdzialow, martwe strzalki karuzeli usuniete.
- Mapa: "Szukaj na biezacej mapie" zaweza liste do widocznych ofert
  (?obszar= w adresie).
- Porownanie: wybrane oferty w adresie (?oferty=), "Udostepnij
  porownanie" kopiuje link.
- Kafelek miast prowadzi na mape, karuzela opinii bez atrap strzalek.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 08:08:45 +02:00
debian 017ff4b2c6 Negocjacje na prawdziwych danych zamiast zmyslonego modelu
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Nowa strona /negocjacje/:listingId porownuje cene ogloszenia sprzedazy
z cenami transakcyjnymi NBP i GUS oraz srednia ogloszen w dzielnicy
i miescie. Przedzial: od wartosci wg cen transakcyjnych do polowy
nadwyzki ceny ofertowej. W ogloszeniu przycisk "Ile moge utargowac?".
Usuniety fikcyjny model negocjacji i tabela cen za m2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:56:47 +02:00
debian 556dd6cf6a Popraw naglowek raportu wyceny PDF
Numer raportu mial zerowy prawy margines i wychodzil poza krawedz strony.
Nazwa serwisu w PDF-ach pisana jak w logo: "Polska Lokalnie".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:46:58 +02:00
debian bad91dc205 Nadaj adresy krokom wyceny i popraw pusty wynik
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Kroki kreatora maja wlasne trasy (/wycena/szczegoly, /wycena/stan,
/wycena/wynik), a szkic formularza przetrwa F5. Pusty wynik wskazuje
brakujace dane linkami do kroku, "Co dalej?" prowadzi do prawdziwych stron.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:35:34 +02:00
debian 94dd300c53 Wycena na prawdziwych danych zamiast zmyslonej tabeli cen
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Stawka bazowa za m2 pochodzila z tabeli opisanej jako "SonarHome 2026"
i zmyslonych mnoznikow dzielnic. Teraz korzysta z /api/price-history:
- najnowsza cena transakcyjna mieszkan NBP dla miasta i rynku, potem GUS
  (dane najwyzej 3-letnie), a bez nich srednia cen ofertowych z ogloszen;
- korekta dzielnicy liczona z ogloszen (min. 3 w dzielnicy i 5 w miescie);
- domy: srednia z ogloszen domow, gdy sa co najmniej 3, inaczej ceny
  mieszkan z korekta modelu - opisane przy wyniku;
- zrodlo stawki w podgladzie, zalozeniach i raporcie PDF;
- brak danych dla lokalizacji to komunikat zamiast wymyslonej kwoty.

Korekty modelu (stan, pietro, rok budowy, udogodnienia) bez zmian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:21:22 +02:00
debian e92c68ec93 Historia cen na prawdziwych danych: ogloszenia, NBP i GUS
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Strona /historia-cen pokazywala pusty wykres (wczesniej zmyslony). Teraz:

Ceny ofertowe z ogloszen:
- Tabela price_snapshots: codzienny zapis srednich cen za m2 z opublikowanych
  ogloszen per miasto i dzielnica, typ (mieszkanie/dom) i rynek. Zapis co noc
  (02:30) i przy starcie, gdy brakuje dzisiejszego. Historii sprzed pierwszego
  zapisu nie odtwarzamy.

Ceny transakcyjne ze zrodel publicznych (porownanie, cale miasto, mieszkania):
- NBP: plik ceny_mieszkan.xlsx (baza BaRN) - 17 miast, rynek pierwotny
  i wtorny, od III kw. 2006. Odczyt XLSX bez nowych zaleznosci.
- GUS: API Banku Danych Lokalnych (temat P3794) - miasta na prawach powiatu,
  kwartalnie od 2010, z opoznieniem ok. roku.
- Import raz w tygodniu i przy starcie, gdy dane sa starsze niz tydzien;
  blad zrodla nie kasuje poprzednich danych.
- GET /api/price-history (publiczny): historia, biezaca srednia z ogloszen,
  od kiedy zbieramy dane oraz serie NBP i GUS.

Frontend:
- Okresy "Ostatnie 5 lat" (domyslny), "Od 2006 (NBP)", "2026 do dzis";
  trzy serie z legenda, dymek ze wszystkimi zrodlami, statystyki i wnioski
  z prawdziwych liczb, porownanie cen ofertowych z NBP i GUS oraz roznica
  i srednia, gdy zrodla sie roznia. Ceny w "Porownaj lokalizacje".
- Poprawione dopasowanie lokalizacji ("Warszawa" otwierala "Warszawa,
  Mokotów"), usuniete resztki zmyslonych cen i opcja "Lokal uzytkowy".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:56:26 +02:00
debian e7402dbfed Osadz logo w mailach, popraw naglowek i nadaj adresy krokom logowania
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Maile:
- Logo jest zalacznikiem inline (cid:) zamiast obrazka z adresu serwisu.
  Wczesniej w mailach z lokalnego srodowiska (i w programach blokujacych
  obrazki z zewnatrz) w miejscu logo byla ikona bledu. Usuniety tekst
  zastepczy, ktory dublowal napis "Polska Lokalnie".

Naglowek:
- Menu nie nachodzi na logo ani panel przy zadnej szerokosci 1110-1920 px.
  Ponizej 1560 px "Ulubione" to samo serce (nazwa w dymku), mniejsze odstepy
  i przycisk "Dodaj ogloszenie" bez sztywnej szerokosci; w 1101-1270 px
  mniejszy napis logo i tekst przycisku.

Logowanie i rejestracja (standard routingu):
- Kazdy krok ma wlasny adres: /logowanie, /logowanie/nie-pamietam-hasla,
  /rejestracja, /rejestracja/osoba-prywatna, /rejestracja/firma,
  /rejestracja/sprawdz-poczte. Dzialaja F5, Wstecz/Dalej i nowa karta,
  formularz czysci sie takze przy Wstecz/Dalej, a cel powrotu po
  zalogowaniu nie ginie przy przejsciach miedzy krokami.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:26:47 +02:00
debian 72af24469a Popraw rejestracje, kod SMS, opisy wysylki i widok wiadomosci
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Rejestracja (/logowanie):
- Formularz rejestracji (osoba prywatna i firma) zawsze zaczyna sie pusty.
  Logowanie i rejestracja dzielily pola e-mail i hasla, wiec dane wpisane
  albo podstawione przez przegladarke przy logowaniu trafialy do nowego konta.
- "Powtorz haslo" ma wlasny przelacznik widocznosci; wczesniej oko przy
  "Haslo" odslanialo oba pola, a ikona przy "Powtorz haslo" nie reagowala.
- Przycisk oka ma normalny obszar klikniecia (mial zerowy rozmiar).

Kod SMS:
- Okno potwierdzania numeru odlicza czas od SMS-a z rejestracji, a odmowa
  serwera ("Kolejny mozesz zamowic za N s") odlicza czas w dol zamiast
  pokazywac stala liczbe sekund. To samo w ustawieniach konta.

Panel admina:
- "Wysylka kampanii e-mail aktywna" -> "Wysylka e-maili aktywna" z opisem:
  przelacznik blokuje wszystkie e-maile (aktywacja, reset hasla,
  powiadomienia), nie tylko kampanie. Podobny opis przy SMS.

Wiadomosci:
- Puste pole pisania bez "Napisz wiadomosc do ...".
- Naglowek rozmowy "Nazwa - opis" zamiast sklejonego tekstu.
- Na liscie rozmow jedna miniatura ogloszenia zamiast dwoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 09:30:21 +02:00
debian 9615f131f4 Dodaj skrot do wiadomosci w pasku gornym
Deploy Polska Lokalnie / deploy (push) Successful in 10s
Obok "Ulubione" i dzwonka powiadomien - dla kazdego zalogowanego konta,
takze administratora - ikona prowadzaca do /wiadomosci z plakietka liczby
nieprzeczytanych, na wzor Gieldy Komunalnej.

Licznik nieprzeczytanych wydzielony do useMessagesUnreadCount, wspolnego
dla menu konta i paska gornego; odswieza sie co 25 s i po zmianie strony.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:26:08 +02:00
debian 5eee33b986 Dodaj prawdziwe wiadomosci miedzy uzytkownikami o ogloszeniach
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Dotad rozmowy z kupujacymi istnialy tylko w przegladarce nadawcy: nic nie
trafialo na serwer, "odczytano" i odpowiedz sprzedajacego byly symulowane
timerem. Wzorem Gieldy Komunalnej:

Backend (/api/conversations):
- Conversation: jeden watek na pare (ogloszenie, kupujacy), sprzedajacy to
  wlasciciel ogloszenia; tytul ogloszenia zapisany na wypadek jego usuniecia.
  Wiadomosci w istniejacej tabeli messages z nowa kolumna conversation_id,
  wiec rozmowa z administracja (conversation_id = null) dziala bez zmian.
- start/list/get/reply, blokada (UserBlock, w obu kierunkach), ukrycie
  rozmowy u siebie (nowa wiadomosc przywraca ja obu stronom). Dostep tylko
  dla stron rozmowy, filtr niedozwolonych slow jak w reszcie serwisu.
- Po zatwierdzeniu transakcji odbiorca dostaje powiadomienie w serwisie
  i - jesli ma wlaczone powiadomienia o wiadomosciach - e-mail (w tle).
- /api/messages/unread-count wlicza rozmowy o ogloszeniach.
- Zgloszenie uzytkownika z rozmowy: zgloszonego, ogloszenie i tresc rozmowy
  serwer bierze z bazy, nie z przegladarki.

Frontend:
- Adresy /wiadomosci/:conversationId, /wiadomosci/administracja
  i /wiadomosci/nowa/:listingId zamiast rozmowy w location.state.
- Lista rozmow odswiezana co 30 s, otwarta rozmowa co 4 s.
- Zdjecia w wiadomosciach usuniete - serwer ich nie przyjmuje (rozmowa
  z administracja i tak je gubila).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 23:15:06 +02:00
debian d655bcc9b1 Usun makiety i fikcyjne dane z frontendu
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Strony pokazywaly zmyslone dane jako prawdziwe. Usuwam je i zostawiam
puste stany tam, gdzie funkcja nie ma jeszcze zrodla danych:

- strona glowna: 4 fikcyjne "Polecane ogloszenia" (pokazywane przy mniej
  niz 4 ogloszeniach) - teraz prawdziwe ogloszenia albo pusty stan;
- mapa: zapasowe fikcyjne oferty kupna i wynajmu;
- wyszukiwarka: gotowe wpisy "Ostatnio wyszukiwane";
- negocjacje: zmyslona "szansa na zakup" i "podobne transakcje" - pusty stan;
- historia cen: wykres i statystyki z generatora - puste do czasu podpiecia API;
- alerty cenowe: wykres z generatora; srednia liczona tylko z ogloszen.
  Wczesniej przy braku ogloszen podstawiana byla stawka szacunkowa, przez co
  monitoring w tle zglaszal nieistniejace zmiany cen;
- spotkania: przykladowe i "testowe" spotkania dopisywane kazdemu kontu,
  takze odfiltrowane z localStorage;
- wiadomosci: 7 fikcyjnych rozmow; pusty stan zamiast bledu bez watkow;
- moje oferty: 12 ukrytych fikcyjnych ofert zapisywanych w localStorage;
- poradniki: zmyslone polecane artykuly (teraz najczesciej czytane z API)
  i liczniki "5 120 ofert";
- wycena: wskaznik "% dokladnosci" liczony z liczby wypelnionych pol;
- martwy kod z fikcyjnymi powiadomieniami, znacznikami mapy i statystykami.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 15:29:21 +02:00
debian bc2f5ce634 Dodaj skrypt czyszczenia danych testowych i wylacz ogloszenia demo
Deploy Polska Lokalnie / deploy (push) Successful in 10s
scripts/cleanup-test-data.sql zostawia wylacznie konta zatorskipascal@gmail.com
i admin@softspm.com. Usuwa pozostale konta, ogloszenia spoza tych dwoch kont
oraz powiazane dane (wiadomosci, powiadomienia, ustawienia, leady, zamowienia
promocji, zgloszenia, poradniki), w tym sieroty po kontach usunietych wczesniej
z panelu admina. Domyslnie dziala na sucho (ROLLBACK), zapis tylko z -v apply=1;
przerywa, gdy ktoregos z zachowywanych kont nie ma w bazie.

DataSeeder nie dodaje juz ogloszen demo: robil to przy kazdym starcie z pusta
tabela ogloszen, wiec po wyczyszczeniu danych wracalyby przy kolejnym wdrozeniu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:36:14 +02:00
debian 4453ef2305 Usun symulowane logowanie przez Google i Facebook
Deploy Polska Lokalnie / deploy (push) Successful in 30s
Endpoint /api/auth/social byl atrapa bez prawdziwego OAuth: wydawal token
sesji dla dowolnego adresu e-mail podanego w zadaniu, bez hasla. Podanie
adresu administratora dawalo pelna sesje z rola ADMIN.

Na tym etapie logowanie ma byc wylacznie dla uzytkownikow systemu (e-mail
i haslo), wiec usuwam je w calosci:

- backend: endpoint, wpis permitAll w SecurityConfig, AuthService.socialLogin
  z pomocnikami i DTO SocialLoginRequest. Enum AuthProvider zostaje, bo
  istniejace konta moga miec GOOGLE/FACEBOOK.
- frontend: przyciski Google/Facebook w formularzach logowania i rejestracji,
  socialLogin w AuthContext oraz osierocone style.

Test regresyjny AuthControllerTest nie uruchomi sie jeszcze na H2 - start
kontekstu wywraca sie na split_part w SQL naprawiajacym nicki (ten sam blad
dotyczy istniejacego contextLoads). Zweryfikowane recznie na stacku
z PostgreSQL: /api/auth/social -> 403, logowanie e-mailem -> 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 14:20:32 +02:00
pascal a3e4505194 emotki i profile
Deploy Polska Lokalnie / deploy (push) Successful in 15s
2026-09-08 16:59:53 +02:00
pascal 6b334cc1b5 poprawki
Deploy Polska Lokalnie / deploy (push) Successful in 13s
2026-09-06 01:05:09 +02:00
pascal fbfb0db38e ladowanie ogloszen
Deploy Polska Lokalnie / deploy (push) Successful in 11s
2026-09-05 21:10:33 +02:00
pascal c981b3cb93 blokady
Deploy Polska Lokalnie / deploy (push) Successful in 11s
2026-09-05 20:54:11 +02:00
pascal e7db409828 wyglad ogloszen
Deploy Polska Lokalnie / deploy (push) Successful in 11s
2026-09-04 14:24:11 +02:00
debian 762b76f141 Opisz wdrazanie na produkcje i pulapke nazw projektow Compose
Deploy Polska Lokalnie / deploy (push) Successful in 5s
Trzy rzeczy kosztowaly dzis najwiecej czasu i zadnej nie dalo sie wyczytac z repozytorium:
zmienne z pliku srodowiskowego nie docieraja do kontenera, jesli nie ma ich w sekcji
environment; wszystkie aplikacje na serwerze leza w katalogach "app", wiec Compose bez -p
wchodzi w cudzy stack; loginem jest e-mail, a nie nick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 01:47:36 +02:00
debian cc96c06c84 Napraw logowanie z domeny produkcyjnej i odpornosc stacku na restart
Deploy Polska Lokalnie / deploy (push) Failing after 0s
Logowanie z https://polskalokalnie.pl konczylo sie bledem 403, a nie 401 -
zadanie w ogole nie docieralo do logiki uwierzytelniania. Nginx terminuje TLS
i przekazuje ruch do backendu po http, wiec Spring widzi schemat http, podczas
gdy przegladarka przysyla Origin z https. Schematy sie roznia, wiec zadanie
jest traktowane jako cross-origin i sprawdzane wzgledem listy dozwolonych
originow, na ktorej byly wylacznie adresy lokalne i LAN. Frontend pokazywal
przy tym generyczny komunikat, bo odpowiedz CORS nie jest JSON-em z pole
"message".

Dodanie domeny produkcyjnej do listy przywraca poprawne zachowanie: zle haslo
zwraca 401 z czytelnym komunikatem, poprawne 200.

Poza tym:

- name: polskalokalnie w obu plikach compose. Bez tego Compose wyprowadza nazwe
  projektu z nazwy katalogu, a na serwerze kazda z trzech aplikacji siedzi w
  katalogu "app" - komenda bez -p wchodzila w stack innej aplikacji i podmieniala
  w nim serwis backend.
- restart: unless-stopped na serwisach produkcyjnych. Kontenery polskalokalnie
  mialy polityke "no" i jako jedyne na serwerze nie wstawaly po reboocie.
  W pliku deweloperskim swiadomie tego nie ma - lokalnie kontenery nie powinny
  startowac same przy kazdym uruchomieniu Dockera.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 01:37:35 +02:00
debian ee83ac43c4 naprawa hasła
Deploy Polska Lokalnie / deploy (push) Successful in 10s
2026-09-04 00:40:22 +02:00
pascal 282b5e424f dodawanie ogloszen poprawki
Deploy Polska Lokalnie / deploy (push) Successful in 10s
2026-09-03 23:28:43 +02:00
pascal d973b44e47 poprawki
Deploy Polska Lokalnie / deploy (push) Successful in 12s
2026-09-03 16:49:33 +02:00
debian 52e1f2cb96 test
Deploy Polska Lokalnie / deploy (push) Successful in 8s
2026-09-03 14:26:46 +02:00
debian 642030ea41 Merge branch 'main' of https://git.softspm.com/softspm/polskalokalnie
Deploy Polska Lokalnie / deploy (push) Successful in 16s
2026-09-03 14:19:27 +02:00
debian 7aad27a514 test 2026-09-03 14:19:22 +02:00
158 changed files with 18559 additions and 5643 deletions
+23
View File
@@ -9,3 +9,26 @@ SPRING_JPA_HIBERNATE_DDL_AUTO=update
WEB_BIND_ADDRESS=0.0.0.0 WEB_BIND_ADDRESS=0.0.0.0
BACKEND_BIND_ADDRESS=127.0.0.1 BACKEND_BIND_ADDRESS=127.0.0.1
POSTGRES_BIND_ADDRESS=127.0.0.1 POSTGRES_BIND_ADDRESS=127.0.0.1
# --- Konto administratora ---
# Uzywane przy pierwszym starcie do zalozenia konta. Logowanie odbywa sie ADRESEM E-MAIL.
APP_ADMIN_EMAIL=admin@softspm.com
APP_ADMIN_PASSWORD=Admin123!
APP_ADMIN_NAME=Administrator Polska Lokalnie
# Gdy konto administratora juz istnieje, APP_ADMIN_PASSWORD jest ignorowane - inaczej restart
# cofalby haslo zmienione w ustawieniach konta. Ustaw ponizsze na true, zrestartuj backend i
# wroc do false, zeby awaryjnie przywrocic haslo z APP_ADMIN_PASSWORD.
APP_ADMIN_RESET_PASSWORD=false
# --- Sesje ---
# Min. 32 znaki. W produkcji ustaw wlasna wartosc - domyslna zna kazdy, kto widzial repozytorium.
APP_JWT_SECRET=local-dev-secret-change-me-please-32chars-min
# Adres bazowy w linkach z e-maili (aktywacja konta, reset hasla).
APP_PUBLIC_BASE_URL=http://localhost
# --- Platnosci ---
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest potwierdzany automatycznie.
# Wylacznie lokalnie (docker-compose.yml). Produkcja tej zmiennej nie czyta.
APP_PAYMENTS_AUTO_CONFIRM=true
+39
View File
@@ -111,3 +111,42 @@ frontend/ React + Vite
nginx/ konfiguracja Nginx i obraz web nginx/ konfiguracja Nginx i obraz web
docker-compose.yml docker-compose.yml
``` ```
## Jakiś testowy wpis
## Wdrozenie na produkcje
Deploy jest automatyczny: push na `main` uruchamia workflow Gitea, ktory wola
`/opt/polskalokalnie/deploy-prod.sh` na serwerze. Skrypt pobiera `main`, ustawia katalog
aplikacji dokladnie na stan repozytorium (`git reset --hard`) i przebudowuje stack.
Konfiguracja produkcyjna siedzi w `/opt/polskalokalnie/app/.env.production`, ktory jest
w `.gitignore`. Zmienne trafiaja do kontenera tylko wtedy, gdy sa wymienione w sekcji
`environment` w `docker-compose.prod.yml` - samo dopisanie ich do pliku `.env` nie wystarczy.
Reczne zmiany plikow sledzonych na serwerze sa przy kolejnym deployu nadpisywane, a ich lista
trafia do `/opt/polskalokalnie/deploy.log` jako `OSTRZEZENIE`. Poprawki nalezy wprowadzac
w repozytorium, nie na serwerze.
### Nazwa projektu Compose
Oba pliki compose maja `name: polskalokalnie`. To nie jest ozdobnik: na serwerze produkcyjnym
kazda z hostowanych aplikacji lezy w katalogu o nazwie `app`, a Compose bez tego wpisu
wyprowadza nazwe projektu z nazwy katalogu. Komenda `docker compose ...` bez `-p` wchodzila
wtedy w stack innej aplikacji i podmieniala w nim serwis `backend`.
Przy recznych operacjach na serwerze zawsze podawaj pelny zestaw parametrow:
```bash
docker compose -p polskalokalnie -f docker-compose.prod.yml --env-file .env.production ps
```
### Logowanie
Loginem jest **adres e-mail**. Nick jest nadawany automatycznie i sluzy wylacznie jako adres
profilu publicznego - nie mozna sie nim zalogowac.
Konto administratora powstaje przy pierwszym starcie z `APP_ADMIN_EMAIL` i `APP_ADMIN_PASSWORD`.
Gdy konto o tym adresie juz istnieje, haslo z konfiguracji jest ignorowane, zeby restart nie
cofal hasla zmienionego w ustawieniach konta. Awaryjne przywrocenie dostepu:
`APP_ADMIN_RESET_PASSWORD=true`, restart backendu, zalogowanie, powrot do `false`.
+4 -3
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version> <version>3.5.16</version>
<relativePath/> <relativePath/>
</parent> </parent>
@@ -64,9 +64,10 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Testy na prawdziwym PostgreSQL (tymczasowy kontener), tak jak produkcja - bez emulacji H2. -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>org.testcontainers</groupId>
<artifactId>h2</artifactId> <artifactId>postgresql</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
@@ -3,12 +3,15 @@ package pl.polskalokalnie;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego // Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
// uzytkownika Spring Security (i mylacy log "Using generated security password"). // uzytkownika Spring Security (i mylacy log "Using generated security password").
// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu. // @EnableScheduling wlacza workera wysylajacego kampanie z outboxu.
// @EnableAsync - e-mail o nowej wiadomosci wychodzi w tle, zeby SMTP nie opoznial wysylki.
@EnableScheduling @EnableScheduling
@EnableAsync
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
public class PolskaLokalnieApplication { public class PolskaLokalnieApplication {
@@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
import pl.polskalokalnie.lead.AdvertisingEnquiryService; import pl.polskalokalnie.lead.AdvertisingEnquiryService;
import pl.polskalokalnie.support.SupportMessageResponse; import pl.polskalokalnie.support.SupportMessageResponse;
import pl.polskalokalnie.support.SupportMessageService; import pl.polskalokalnie.support.SupportMessageService;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.BlockedEmail; import pl.polskalokalnie.user.BlockedEmail;
import pl.polskalokalnie.user.BlockedEmailRepository; import pl.polskalokalnie.user.BlockedEmailRepository;
import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@RestController @RestController
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
@@ -64,6 +66,8 @@ public class AdminController {
private final AdminStatsService adminStatsService; private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final NotificationService notificationService; private final NotificationService notificationService;
private final AccountErasureService accountErasureService;
private final UserSettingsService userSettingsService;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository, public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService, ListingService listingService, ListingReportService listingReportService,
@@ -74,7 +78,9 @@ public class AdminController {
TextModerationService textModerationService, TextModerationService textModerationService,
AdminStatsService adminStatsService, AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
NotificationService notificationService) { NotificationService notificationService,
AccountErasureService accountErasureService,
UserSettingsService userSettingsService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository; this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService; this.listingService = listingService;
@@ -87,6 +93,8 @@ public class AdminController {
this.adminStatsService = adminStatsService; this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService; this.notificationService = notificationService;
this.accountErasureService = accountErasureService;
this.userSettingsService = userSettingsService;
} }
// --- Uzytkownicy --- // --- Uzytkownicy ---
@@ -166,6 +174,8 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword(); String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword)); user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
user.revokeSessions();
userRepository.save(user); userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock", notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane", "Hasło zostało zresetowane",
@@ -181,6 +191,7 @@ public class AdminController {
if (user.getRole() == Role.ADMIN) { if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
} }
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user); userRepository.delete(user);
} }
@@ -104,7 +104,8 @@ public class AdminStatsService {
List<Message> messagesToAdmin = adminId == null List<Message> messagesToAdmin = adminId == null
? List.of() ? List.of()
: messages.stream() : messages.stream()
.filter(message -> message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId)) .filter(message -> message.getConversationId() == null
&& message.getRecipientId().equals(adminId) && !message.getSenderId().equals(adminId))
.toList(); .toList();
AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary( AdminStatsResponse.Summary summary = new AdminStatsResponse.Summary(
@@ -4,6 +4,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.util.HtmlUtils;
import pl.polskalokalnie.mailconfig.MailConfigService; import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmtpConfig; import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender; import pl.polskalokalnie.send.EmailSender;
@@ -70,7 +71,7 @@ public class AccountEmailService {
boolean approved, String reason, String path) { boolean approved, String reason, String path) {
String link = baseUrl + (path == null ? "" : path); String link = baseUrl + (path == null ? "" : path);
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty"; String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
String safeTitle = articleTitle == null ? "" : articleTitle; String safeTitle = articleTitle == null ? "" : HtmlUtils.htmlEscape(articleTitle);
String body; String body;
if (approved) { if (approved) {
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została" body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
@@ -79,7 +80,7 @@ public class AccountEmailService {
} else { } else {
String reasonHtml = reason == null || reason.isBlank() ? "" String reasonHtml = reason == null || reason.isBlank() ? ""
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> " : "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
+ reason.trim() + "</p>"; + HtmlUtils.htmlEscape(reason.trim()) + "</p>";
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół" body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>" + " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
+ reasonHtml + reasonHtml
@@ -90,6 +91,43 @@ public class AccountEmailService {
approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link); approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link);
} }
/** Nowa wiadomosc w rozmowie o ogloszeniu. Tresc pochodzi od uzytkownika, wiec jest escapowana. */
public void sendNewMessage(String email, String name, String senderName, String listingTitle,
String content, Long conversationId) {
String link = baseUrl + "/wiadomosci/" + conversationId;
String safeContent = HtmlUtils.htmlEscape(content == null ? "" : content).replace("\n", "<br>");
String body = "<p><strong>" + HtmlUtils.htmlEscape(senderName) + "</strong> napisał(a) w sprawie ogłoszenia"
+ " <strong>„" + HtmlUtils.htmlEscape(listingTitle) + "”</strong>:</p>"
+ "<blockquote style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;"
+ "border-radius:6px;\">" + safeContent + "</blockquote>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Odpowiedz w serwisie - nie podawaj w wiadomościach danych"
+ " logowania, numeru konta ani kodów BLIK.</p>";
send(email, "Nowa wiadomość w sprawie: " + listingTitle,
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
}
/**
* Potwierdzony termin ogladania. Jedyny e-mail modulu spotkan - propozycje, odmowy
* i odwolania zostaja w serwisie (watek rozmowy i dzwonek).
*/
public void sendMeetingConfirmed(String email, String name, String otherName, String listingTitle,
String when, String location, Long conversationId) {
String link = baseUrl + (conversationId == null ? "/konto/spotkania" : "/wiadomosci/" + conversationId);
String where = location == null || location.isBlank() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Miejsce:</strong> " + HtmlUtils.htmlEscape(location) + "</p>";
String about = listingTitle == null || listingTitle.isBlank() ? "" :
"<p style=\"margin:0 0 4px\"><strong>Ogłoszenie:</strong> " + HtmlUtils.htmlEscape(listingTitle) + "</p>";
String body = "<p>Termin oglądania został potwierdzony przez: <strong>"
+ HtmlUtils.htmlEscape(otherName) + "</strong>.</p>"
+ "<div style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;border-radius:6px;\">"
+ "<p style=\"margin:0 0 4px\"><strong>Termin:</strong> " + HtmlUtils.htmlEscape(when) + "</p>"
+ where + about + "</div>"
+ "<p style=\"color:#6b7f96;font-size:13px\">Jeśli termin przestanie pasować, odwołaj go w serwisie -"
+ " druga strona od razu to zobaczy.</p>";
send(email, "Potwierdzone oglądanie: " + when,
template("Termin potwierdzony", greeting(name), body, "Zobacz szczegóły", link), "SPOTKANIE", link);
}
private void send(String email, String subject, String html, String label, String link) { private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp(); SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled() boolean configured = smtp.isEnabled()
@@ -105,8 +143,10 @@ public class AccountEmailService {
} }
} }
private static String greeting(String name) { // Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0]; // escapowane - inaczej prawdziwy mail z serwisu niosl by link lub tresc atakujacego.
static String greeting(String name) {
String who = name == null || name.isBlank() ? "" : " " + HtmlUtils.htmlEscape(name.trim().split("\\s+")[0]);
return "<p>Cześć" + who + ",</p>"; return "<p>Cześć" + who + ",</p>";
} }
@@ -116,7 +156,7 @@ public class AccountEmailService {
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">" + "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b;\">"
// Naglowek z logo + nazwa // Naglowek z logo + nazwa
+ "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">" + "<div style=\"background:#0f2340;border-radius:14px 14px 0 0;padding:20px 24px;\">"
+ "<img src=\"" + baseUrl + "/favicon.png\" width=\"34\" height=\"34\" alt=\"Polska Lokalnie\"" + "<img src=\"cid:" + EmailSender.LOGO_CID + "\" width=\"34\" height=\"34\" alt=\"\""
+ " style=\"vertical-align:middle;border-radius:8px;\">" + " style=\"vertical-align:middle;border-radius:8px;\">"
+ "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">" + "<span style=\"vertical-align:middle;margin-left:10px;font-size:20px;font-weight:bold;color:#ffffff;\">"
+ "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>" + "Polska <span style=\"color:#35d38a;\">Lokalnie</span></span>"
@@ -23,7 +23,6 @@ import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest; import pl.polskalokalnie.auth.dto.LoginRequest;
import pl.polskalokalnie.auth.dto.RegisterRequest; import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse; import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest; import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse; import pl.polskalokalnie.auth.dto.UserResponse;
@@ -47,11 +46,6 @@ public class AuthController {
return authService.login(request); return authService.login(request);
} }
@PostMapping("/social")
public AuthResponse social(@Valid @RequestBody SocialLoginRequest request) {
return authService.socialLogin(request);
}
// --- Aktywacja konta --- // --- Aktywacja konta ---
@PostMapping("/activate") @PostMapping("/activate")
@@ -118,10 +112,10 @@ public class AuthController {
// --- Operacje na wlasnym koncie z /konto/ustawienia --- // --- Operacje na wlasnym koncie z /konto/ustawienia ---
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
@PostMapping("/change-password") @PostMapping("/change-password")
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) { public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword()); return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
} }
/** /**
@@ -3,8 +3,6 @@ package pl.polskalokalnie.auth;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Base64; import java.util.Base64;
import java.util.Locale; import java.util.Locale;
@@ -14,14 +12,15 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.lead.LeadSyncService; import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse; import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest; import pl.polskalokalnie.auth.dto.LoginRequest;
import pl.polskalokalnie.auth.dto.RegisterRequest; import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse; import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest; import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse; import pl.polskalokalnie.auth.dto.UserResponse;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider; import pl.polskalokalnie.user.AuthProvider;
@@ -53,6 +52,8 @@ public class AuthService {
private final PhoneVerificationService phoneVerificationService; private final PhoneVerificationService phoneVerificationService;
private final NickService nickService; private final NickService nickService;
private final UserSettingsService userSettingsService; private final UserSettingsService userSettingsService;
private final AccountErasureService accountErasureService;
private final ListingService listingService;
public AuthService( public AuthService(
UserRepository userRepository, UserRepository userRepository,
@@ -65,7 +66,9 @@ public class AuthService {
AccountEmailService accountEmailService, AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService, PhoneVerificationService phoneVerificationService,
NickService nickService, NickService nickService,
UserSettingsService userSettingsService UserSettingsService userSettingsService,
AccountErasureService accountErasureService,
ListingService listingService
) { ) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository; this.blockedEmailRepository = blockedEmailRepository;
@@ -78,6 +81,8 @@ public class AuthService {
this.phoneVerificationService = phoneVerificationService; this.phoneVerificationService = phoneVerificationService;
this.nickService = nickService; this.nickService = nickService;
this.userSettingsService = userSettingsService; this.userSettingsService = userSettingsService;
this.accountErasureService = accountErasureService;
this.listingService = listingService;
} }
@Transactional @Transactional
@@ -105,7 +110,14 @@ public class AuthService {
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null; String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
user.setPhone(phone); user.setPhone(phone);
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null); user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); // Adres zapisujemy tylko firmom - konto prywatne go nie potrzebuje (minimalizacja danych).
if (user.getAccountType() == AccountType.COMPANY) {
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
}
// Rejestracja jest mozliwa tylko po zaznaczeniu zgody w formularzu - zapisujemy jej moment.
if (Boolean.TRUE.equals(request.acceptTerms())) {
user.setTermsAcceptedAt(Instant.now());
}
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane. // Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
user.setVerified(false); user.setVerified(false);
user.setPhoneVerified(false); user.setPhoneVerified(false);
@@ -145,6 +157,8 @@ public class AuthService {
"Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny."); "Konto nie zostało aktywowane. Sprawdź skrzynkę e-mail i kliknij link aktywacyjny.");
} }
user.setLastActiveAt(Instant.now());
userRepository.save(user);
return buildAuthResponse(user); return buildAuthResponse(user);
} }
@@ -190,6 +204,8 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail()) AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
user.revokeSessions();
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne. // Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
user.setVerified(true); user.setVerified(true);
userRepository.save(user); userRepository.save(user);
@@ -209,8 +225,12 @@ public class AuthService {
*/ */
public void resendPhoneOtp(String email) { public void resendPhoneOtp(String email) {
String normalized = normalizeEmail(email); String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized) // Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje")); // zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
if (user == null) {
return;
}
if (user.getPhone() == null || user.getPhone().isBlank()) { if (user.getPhone() == null || user.getPhone().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
} }
@@ -259,49 +279,20 @@ public class AuthService {
return authToken; return authToken;
} }
/** // Odstep, po ktorym wejscie do aplikacji (odswiezenie /auth/me) na nowo odnotowuje aktywnosc.
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca. // Bez tego zapisywalibysmy do bazy przy kazdym zadaniu profilu - a wystarczy zgrubna dokladnosc.
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo. private static final Duration LAST_ACTIVE_REFRESH = Duration.ofMinutes(2);
*/
public AuthResponse socialLogin(SocialLoginRequest request) {
AuthProvider provider = request.provider();
String email = normalizeEmail(
request.email() != null && !request.email().isBlank()
? request.email()
: defaultSocialEmail(provider));
String name = request.fullName() != null && !request.fullName().isBlank()
? request.fullName().trim()
: defaultSocialName(provider);
textModerationService.validateOrThrow(name);
AppUser user = userRepository.findByEmailIgnoreCase(email).orElseGet(() -> {
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
}
AppUser created = new AppUser();
created.setEmail(email);
created.setFullName(name);
created.setNick(nickService.generateFromName(name, email, null));
created.setRole(Role.USER);
created.setProvider(provider);
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
created.setVerified(true);
return userRepository.save(created);
});
if (user.isBlocked()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
}
leadSyncService.syncUser(user);
return buildAuthResponse(user);
}
@Transactional
public UserResponse currentUser(String email) { public UserResponse currentUser(String email) {
return userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.map(this::withAvatar)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
Instant now = Instant.now();
if (user.getLastActiveAt() == null || user.getLastActiveAt().isBefore(now.minus(LAST_ACTIVE_REFRESH))) {
user.setLastActiveAt(now);
userRepository.save(user);
}
return withAvatar(user);
} }
/** /**
@@ -317,7 +308,7 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address()); textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.address());
user.setFullName(request.fullName().trim()); user.setFullName(request.fullName().trim());
if (request.nick() != null && !request.nick().isBlank()) { if (request.nick() != null && !request.nick().isBlank()) {
@@ -331,18 +322,20 @@ public class AuthService {
user.setPhoneVerified(false); user.setPhoneVerified(false);
} }
user.setPhone(phone); user.setPhone(phone);
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null); // Adres przyjmujemy wylacznie od kont firmowych (siedziba przedsiebiorcy). Konto prywatne
// nie ma po co podawac adresu zamieszkania, wiec nawet przyslany z zewnatrz zostaje odrzucony
// i kasuje to, co ewentualnie zapisano wczesniej.
if (user.getAccountType() == AccountType.COMPANY) {
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
} else {
user.setAddress(null);
}
// NIP: null = nie zmieniaj (zapis czesciowy nie moze skasowac NIP-u firmy), pusty = wyczysc.
if (request.nip() != null) {
user.setNip(request.nip().isBlank() ? null : request.nip().trim());
}
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE); user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL); user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
if (request.birthDate() != null && !request.birthDate().isBlank()) {
try {
user.setBirthDate(LocalDate.parse(request.birthDate().trim()));
} catch (DateTimeParseException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia");
}
} else {
user.setBirthDate(null);
}
AppUser saved = userRepository.save(user); AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved); leadSyncService.syncUser(saved);
@@ -354,7 +347,7 @@ public class AuthService {
* wiec przejeta sesja nie pozwala przejac konta na stale. * wiec przejeta sesja nie pozwala przejac konta na stale.
*/ */
@Transactional @Transactional
public void changePassword(String email, String currentPassword, String newPassword) { public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
AppUser user = userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -381,6 +374,8 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(newPassword)); user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now()); user.setPasswordChangedAt(Instant.now());
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
user.revokeSessions();
userRepository.save(user); userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla. // Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -390,6 +385,7 @@ public class AuthService {
authTokenRepository.save(old); authTokenRepository.save(old);
} }
}); });
return buildAuthResponse(user);
} }
/** /**
@@ -453,7 +449,9 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
} }
accountErasureService.eraseFor(user.getId(), user.getEmail());
userSettingsService.deleteForUser(user.getId()); userSettingsService.deleteForUser(user.getId());
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user); userRepository.delete(user);
} }
@@ -475,20 +473,4 @@ public class AuthService {
String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName(); String source = fullName != null && !fullName.isBlank() ? fullName : user.getFullName();
return nickService.generateFromName(source, user.getEmail(), user); return nickService.generateFromName(source, user.getEmail(), user);
} }
private String defaultSocialEmail(AuthProvider provider) {
return switch (provider) {
case GOOGLE -> "demo.google@gmail.com";
case FACEBOOK -> "demo.facebook@facebook.com";
case LOCAL -> "demo.local@polskalokalnie.pl";
};
}
private String defaultSocialName(AuthProvider provider) {
return switch (provider) {
case GOOGLE -> "Użytkownik Google";
case FACEBOOK -> "Użytkownik Facebook";
case LOCAL -> "Użytkownik";
};
}
} }
@@ -0,0 +1,49 @@
package pl.polskalokalnie.auth;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Sprzatanie jednorazowych tokenow i kodow SMS. Wczesniej zostawaly w bazie bezterminowo,
* mimo ze po wygasnieciu sa bezuzyteczne - a wiersz phone_otps zawiera numer telefonu
* i kod w jawnej postaci. Zasada ograniczenia przechowywania (art. 5 ust. 1 lit. e RODO)
* wymaga, zeby znikaly, gdy przestaja byc potrzebne.
*
* <p>Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje
* czytelny komunikat zamiast bledu "nie znaleziono".</p>
*/
@Component
public class ExpiredTokenCleaner {
private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleaner.class);
/** Bufor po wygasnieciu - tyle jeszcze trzymamy zuzyty albo przeterminowany wpis. */
private static final int GRACE_DAYS = 7;
private final JdbcTemplate jdbcTemplate;
public ExpiredTokenCleaner(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Scheduled(cron = "0 15 3 * * *")
@Transactional
public void cleanDaily() {
Instant cutoff = Instant.now().minus(GRACE_DAYS, ChronoUnit.DAYS);
try {
int tokens = jdbcTemplate.update("DELETE FROM auth_tokens WHERE expires_at < ?", cutoff);
int otps = jdbcTemplate.update("DELETE FROM phone_otps WHERE expires_at < ?", cutoff);
if (tokens > 0 || otps > 0) {
log.info("Sprzatanie: usunieto {} wygaslych tokenow i {} kodow SMS", tokens, otps);
}
} catch (RuntimeException ex) {
log.warn("Sprzatanie wygaslych tokenow nie powiodlo sie: {}", ex.getMessage());
}
}
}
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.time.Instant;
import java.util.List; import java.util.List;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -14,14 +15,20 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Component @Component
public class JwtAuthFilter extends OncePerRequestFilter { public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService; static final String STREAM_PATH = "/api/notifications/stream";
public JwtAuthFilter(JwtService jwtService) { private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository;
} }
@Override @Override
@@ -30,16 +37,29 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain @NonNull FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
String token = resolveToken(request); String header = request.getHeader("Authorization");
if (token != null) { boolean fromHeader = header != null && header.startsWith("Bearer ");
// Token w adresie tylko dla strumienia powiadomien (EventSource nie ustawia naglowkow) i tylko
// krotki token strumienia - token sesji w adresie trafial do logow serwera.
String token = fromHeader ? header.substring(7)
: STREAM_PATH.equals(request.getRequestURI()) ? request.getParameter("ticket") : null;
if (token != null && !token.isBlank()) {
try { try {
Claims claims = jwtService.parse(token); Claims claims = jwtService.parse(token);
String email = claims.getSubject(); boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
String role = claims.get("role", String.class); if (streamToken == fromHeader) {
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); // Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); }
SecurityContextHolder.getContext().setAuthentication(authentication); // Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) { } catch (Exception ex) {
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione. // Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
@@ -48,16 +68,12 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token. static boolean isSessionValid(AppUser user, Instant issuedAt) {
private String resolveToken(HttpServletRequest request) { if (user.isBlocked() || user.getRole() == null) {
String header = request.getHeader("Authorization"); return false;
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
} }
String param = request.getParameter("access_token"); Instant validFrom = user.getSessionsValidFrom();
if (param != null && !param.isBlank()) { return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
return param;
}
return null;
} }
} }
@@ -38,6 +38,26 @@ public class JwtService {
.compact(); .compact();
} }
/** Przeznaczenie tokenu strumienia powiadomien (claim "purpose"). */
public static final String PURPOSE_STREAM = "sse";
private static final long STREAM_TOKEN_SECONDS = 60;
/**
* Krotki (60 s) token wylacznie do otwarcia strumienia powiadomien. EventSource nie ustawia
* naglowkow, wiec token idzie w adresie - a adresy laduja w logach. Dlatego nie jest to token
* sesji: dziala tylko na /api/notifications/stream i wygasa po minucie.
*/
public String generateStreamToken(String email) {
Instant now = Instant.now();
return Jwts.builder()
.subject(email)
.claim("purpose", PURPOSE_STREAM)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(STREAM_TOKEN_SECONDS)))
.signWith(key)
.compact();
}
public Claims parse(String token) { public Claims parse(String token) {
return Jwts.parser() return Jwts.parser()
.verifyWith(key) .verifyWith(key)
@@ -35,18 +35,28 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/error").permitAll() .requestMatchers("/error").permitAll()
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll() .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation", .requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
"/api/auth/forgot-password", "/api/auth/reset-password", "/api/auth/forgot-password", "/api/auth/reset-password",
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll() "/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
.requestMatchers("/api/i18n/translate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll() .requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll() .requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll() .requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll() .requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated() .requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated() // Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
"/api/listings/favorites").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
// Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu.
.requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated() .requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll() .requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
@@ -7,11 +7,13 @@ import pl.polskalokalnie.user.AccountType;
public record RegisterRequest( public record RegisterRequest(
@NotBlank @Email String email, @NotBlank @Email String email,
@NotBlank @Size(min = 6, max = 72) String password, @NotBlank @Size(min = 8, max = 72) String password,
@NotBlank String fullName, @NotBlank @Size(max = 120) String fullName,
AccountType accountType, AccountType accountType,
String phone, String phone,
String nip, String nip,
String address String address,
/** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */
Boolean acceptTerms
) { ) {
} }
@@ -1,15 +0,0 @@
package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.NotNull;
import pl.polskalokalnie.user.AuthProvider;
/**
* Symulowane logowanie spoleczne. W wersji produkcyjnej email/name pochodzilyby
* z tokenu dostawcy OAuth (Google/Facebook); tutaj przychodza z frontendu jako dane demo.
*/
public record SocialLoginRequest(
@NotNull AuthProvider provider,
String email,
String fullName
) {
}
@@ -1,16 +1,20 @@
package pl.polskalokalnie.auth.dto; package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import pl.polskalokalnie.user.ContactPreference; import pl.polskalokalnie.user.ContactPreference;
import pl.polskalokalnie.user.PreferredLanguage; import pl.polskalokalnie.user.PreferredLanguage;
public record UpdateProfileRequest( public record UpdateProfileRequest(
@NotBlank String fullName, @NotBlank @Size(max = 120) String fullName,
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje. // Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
String nick, String nick,
String phone, String phone,
String birthDate, // Adres siedziby - przyjmowany tylko od kont firmowych.
String address, String address,
// NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u);
// pusty ciag = wyczysc. Dotyczy kont firmowych.
String nip,
ContactPreference contactPreference, ContactPreference contactPreference,
PreferredLanguage preferredLanguage PreferredLanguage preferredLanguage
) { ) {
@@ -1,7 +1,6 @@
package pl.polskalokalnie.auth.dto; package pl.polskalokalnie.auth.dto;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider; import pl.polskalokalnie.user.AuthProvider;
@@ -22,7 +21,6 @@ public record UserResponse(
ContactPreference contactPreference, ContactPreference contactPreference,
PreferredLanguage preferredLanguage, PreferredLanguage preferredLanguage,
String nip, String nip,
LocalDate birthDate,
boolean verified, boolean verified,
boolean phoneVerified, boolean phoneVerified,
boolean blocked, boolean blocked,
@@ -50,7 +48,6 @@ public record UserResponse(
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE, user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL, user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
user.getNip(), user.getNip(),
user.getBirthDate(),
user.isVerified(), user.isVerified(),
user.isPhoneVerified(), user.isPhoneVerified(),
user.isBlocked(), user.isBlocked(),
@@ -0,0 +1,203 @@
package pl.polskalokalnie.campaign;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Limit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.util.HtmlUtils;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/**
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
*
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
*/
@Component
public class CampaignSender {
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
private static final int BATCH_SIZE = 25;
private final CampaignRepository campaignRepository;
private final CampaignRecipientRepository recipientRepository;
private final LeadRepository leadRepository;
private final MailConfigService mailConfigService;
private final EmailSender emailSender;
private final SmsSender smsSender;
private final UnsubscribeService unsubscribeService;
private final String baseUrl;
public CampaignSender(CampaignRepository campaignRepository,
CampaignRecipientRepository recipientRepository,
LeadRepository leadRepository,
MailConfigService mailConfigService,
EmailSender emailSender,
SmsSender smsSender,
UnsubscribeService unsubscribeService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.campaignRepository = campaignRepository;
this.recipientRepository = recipientRepository;
this.leadRepository = leadRepository;
this.mailConfigService = mailConfigService;
this.emailSender = emailSender;
this.smsSender = smsSender;
this.unsubscribeService = unsubscribeService;
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
}
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
@Transactional
public void processOutbox() {
Instant now = Instant.now();
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
.forEach(campaign -> {
campaign.setStatus(CampaignStatus.SENDING);
campaignRepository.save(campaign);
});
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
try {
sendBatch(campaign);
} catch (RuntimeException ex) {
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
}
}
}
private void sendBatch(Campaign campaign) {
int remainingToday = remainingToday(campaign);
if (remainingToday <= 0) {
return;
}
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
if (batch.isEmpty()) {
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
return;
}
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
if (!senderConfigured(smtp, sms)) {
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
return;
}
int sent = 0;
for (CampaignRecipient recipient : batch) {
if (stillOptedOut(recipient)) {
recipient.setStatus(RecipientStatus.OPTED_OUT);
recipientRepository.save(recipient);
continue;
}
SendResult result = campaign.getChannel() == Channel.EMAIL
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
if (result.ok()) {
recipient.setStatus(RecipientStatus.SENT);
recipient.setSentAt(Instant.now());
recipient.setProviderMessageId(result.providerMessageId());
recipient.setError(null);
sent++;
} else {
recipient.setStatus(RecipientStatus.FAILED);
recipient.setError(trim(result.error()));
}
recipientRepository.save(recipient);
}
campaign.setSentToday(todaysCount(campaign) + sent);
campaign.setSentTodayDate(LocalDate.now());
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
campaignRepository.save(campaign);
return;
}
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
}
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
if (smtp != null) {
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
}
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
}
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
private int remainingToday(Campaign campaign) {
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
return limit - todaysCount(campaign);
}
private int todaysCount(Campaign campaign) {
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
}
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
private boolean stillOptedOut(CampaignRecipient recipient) {
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
if (lead == null) {
return false;
}
return recipient.getChannel() == Channel.EMAIL
? !lead.isEmailConsent() || lead.isEmailOptOut()
: !lead.isSmsConsent() || lead.isSmsOptOut();
}
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
private String unsubscribeLink(CampaignRecipient recipient) {
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
}
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody();
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
String footer = link == null ? ""
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
+ " informacje marketingowe od serwisu Polska Lokalnie. "
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
+ greeting + body + footer + "</div></body></html>";
}
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
return link == null ? body : body + " Rezygnacja: " + link;
}
private static String trim(String value) {
if (value == null) {
return null;
}
return value.length() > 240 ? value.substring(0, 240) : value;
}
}
@@ -85,8 +85,8 @@ public class CampaignService {
/** /**
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania * Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera * z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
* wysylajacego dodajemy w kolejnej fazie. * Wysylke z outboxu obsluguje {@link CampaignSender}.
*/ */
@Transactional @Transactional
public CampaignResponse schedule(Long id) { public CampaignResponse schedule(Long id) {
@@ -1,6 +1,8 @@
package pl.polskalokalnie.config; package pl.polskalokalnie.config;
import java.math.BigDecimal; import java.math.BigDecimal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@@ -8,11 +10,6 @@ import org.springframework.stereotype.Component;
import pl.polskalokalnie.banner.Banner; import pl.polskalokalnie.banner.Banner;
import pl.polskalokalnie.banner.BannerRepository; import pl.polskalokalnie.banner.BannerRepository;
import pl.polskalokalnie.banner.BannerSlot; import pl.polskalokalnie.banner.BannerSlot;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.promotion.PromotionPackage; import pl.polskalokalnie.promotion.PromotionPackage;
import pl.polskalokalnie.promotion.PromotionPackageRepository; import pl.polskalokalnie.promotion.PromotionPackageRepository;
import pl.polskalokalnie.promotion.PromotionPlan; import pl.polskalokalnie.promotion.PromotionPlan;
@@ -26,8 +23,9 @@ import pl.polskalokalnie.user.UserRepository;
@Component @Component
public class DataSeeder implements CommandLineRunner { public class DataSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataSeeder.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final ListingRepository listingRepository;
private final BannerRepository bannerRepository; private final BannerRepository bannerRepository;
private final PromotionPlanRepository promotionPlanRepository; private final PromotionPlanRepository promotionPlanRepository;
private final PromotionPackageRepository promotionPackageRepository; private final PromotionPackageRepository promotionPackageRepository;
@@ -36,21 +34,21 @@ public class DataSeeder implements CommandLineRunner {
private final String adminEmail; private final String adminEmail;
private final String adminPassword; private final String adminPassword;
private final String adminName; private final String adminName;
private final boolean adminResetPassword;
public DataSeeder( public DataSeeder(
UserRepository userRepository, UserRepository userRepository,
ListingRepository listingRepository,
BannerRepository bannerRepository, BannerRepository bannerRepository,
PromotionPlanRepository promotionPlanRepository, PromotionPlanRepository promotionPlanRepository,
PromotionPackageRepository promotionPackageRepository, PromotionPackageRepository promotionPackageRepository,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
NickService nickService, NickService nickService,
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail, @Value("${app.admin.email:admin@softspm.com}") String adminEmail,
@Value("${app.admin.password:Admin123!}") String adminPassword, @Value("${app.admin.password:Admin123!}") String adminPassword,
@Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName @Value("${app.admin.name:Administrator Polska Lokalnie}") String adminName,
@Value("${app.admin.reset-password:false}") boolean adminResetPassword
) { ) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.bannerRepository = bannerRepository; this.bannerRepository = bannerRepository;
this.promotionPlanRepository = promotionPlanRepository; this.promotionPlanRepository = promotionPlanRepository;
this.promotionPackageRepository = promotionPackageRepository; this.promotionPackageRepository = promotionPackageRepository;
@@ -59,12 +57,14 @@ public class DataSeeder implements CommandLineRunner {
this.adminEmail = adminEmail; this.adminEmail = adminEmail;
this.adminPassword = adminPassword; this.adminPassword = adminPassword;
this.adminName = adminName; this.adminName = adminName;
this.adminResetPassword = adminResetPassword;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
seedAdmin(); seedAdmin();
seedDemoListings(); // Bez ogloszen demo: seeder dodawal je przy kazdym starcie z pusta tabela ogloszen,
// wiec po wyczyszczeniu danych testowych na produkcji wracalyby przy kolejnym wdrozeniu.
seedBanners(); seedBanners();
seedPromotion(); seedPromotion();
} }
@@ -135,57 +135,57 @@ public class DataSeeder implements CommandLineRunner {
return banner; return banner;
} }
/**
* Konto administratora z konfiguracji (APP_ADMIN_EMAIL / APP_ADMIN_PASSWORD).
*
* Gdy konta o tym adresie jeszcze nie ma - zakladamy je. Gdy juz istnieje, haslo z konfiguracji
* jest ignorowane, bo inaczej kazdy restart cofalby haslo zmienione w ustawieniach konta.
* Swiadome nadpisanie wlacza APP_ADMIN_RESET_PASSWORD=true - to jedyna droga odzyskania dostepu,
* gdy haslo administratora zostalo zgubione.
*/
private void seedAdmin() { private void seedAdmin() {
if (userRepository.existsByEmailIgnoreCase(adminEmail)) { String email = adminEmail.trim().toLowerCase();
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
if (existing == null) {
if (passwordMissing) {
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
return;
}
AppUser admin = new AppUser();
admin.setEmail(email);
admin.setFullName(adminName);
admin.setNick(nickService.generateUnique(email.split("@")[0]));
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole(Role.ADMIN);
admin.setProvider(AuthProvider.LOCAL);
admin.setVerified(true);
userRepository.save(admin);
log.info("Utworzono konto administratora {}", email);
return; return;
} }
AppUser admin = new AppUser();
admin.setEmail(adminEmail.toLowerCase());
admin.setFullName(adminName);
admin.setNick(nickService.generateUnique(adminEmail.split("@")[0]));
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole(Role.ADMIN);
admin.setProvider(AuthProvider.LOCAL);
admin.setVerified(true);
userRepository.save(admin);
}
private void seedDemoListings() { if (!adminResetPassword) {
if (listingRepository.count() > 0) {
return; return;
} }
listingRepository.save(demoListing( if (passwordMissing) {
"Słoneczne 3 pokoje na Mokotowie", "Rozkładowe mieszkanie po remoncie, blisko metra.", log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
OfferType.SALE, PropertyType.APARTMENT, "Warszawa", "ul. Puławska 120", return;
new BigDecimal("749000"), 62.0, 3, ListingStatus.APPROVED)); }
listingRepository.save(demoListing(
"Dom z ogrodem pod Krakowem", "Wolnostojący dom, działka 600 m², cicha okolica.", // Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
OfferType.SALE, PropertyType.HOUSE, "Kraków", "ul. Podgórska 8", // Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING)); existing.setPasswordHash(passwordEncoder.encode(adminPassword));
listingRepository.save(demoListing( existing.setRole(Role.ADMIN);
"Kawalerka do wynajęcia - Wrocław", "Umeblowana kawalerka w centrum, dostępna od zaraz.", existing.setVerified(true);
OfferType.RENT, PropertyType.APARTMENT, "Wrocław", "ul. Krupnicza 3", existing.setBlocked(false);
new BigDecimal("2600"), 30.0, 1, ListingStatus.PENDING)); userRepository.save(existing);
log.warn("APP_ADMIN_RESET_PASSWORD=true - ustawiono haslo z konfiguracji dla konta {}."
+ " Wylacz te zmienna po odzyskaniu dostepu.", email);
} }
private PropertyListing demoListing(
String title, String description, OfferType offerType, PropertyType propertyType,
String city, String address, BigDecimal price, Double area, Integer rooms, ListingStatus status
) {
PropertyListing listing = new PropertyListing();
listing.setTitle(title);
listing.setDescription(description);
listing.setOfferType(offerType);
listing.setPropertyType(propertyType);
listing.setCity(city);
listing.setAddress(address);
listing.setPrice(price);
listing.setArea(area);
listing.setRooms(rooms);
listing.setContactName("Biuro Polska Lokalnie");
listing.setContactPhone("+48 600 100 200");
listing.setOwnerEmail("demo.user@polskalokalnie.pl");
listing.setStatus(status);
return listing;
}
} }
@@ -1,5 +1,10 @@
package pl.polskalokalnie.config; package pl.polskalokalnie.config;
import java.sql.Connection;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -8,9 +13,32 @@ import org.springframework.jdbc.core.JdbcTemplate;
@Configuration @Configuration
public class SchemaFixer { public class SchemaFixer {
private static final Logger log = LoggerFactory.getLogger(SchemaFixer.class);
/**
* Laty schematu pisane sa skladnia PostgreSQL (DO $$, to_regclass, lo_get, split_part...),
* bo taka baza dziala na serwerze. W testach schemat tworzy Hibernate z encji na H2,
* wiec nie ma czego latac - bez tego warunku kazdy @SpringBootTest wywracal sie przy starcie.
*/
private static boolean isPostgres(JdbcTemplate jdbcTemplate) {
DataSource dataSource = jdbcTemplate.getDataSource();
if (dataSource == null) {
return false;
}
try (Connection connection = dataSource.getConnection()) {
return "PostgreSQL".equalsIgnoreCase(connection.getMetaData().getDatabaseProductName());
} catch (SQLException ex) {
log.warn("Nie udalo sie rozpoznac bazy danych, pomijam poprawki schematu: {}", ex.getMessage());
return false;
}
}
@Bean @Bean
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) { ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
return args -> { return args -> {
if (!isPostgres(jdbcTemplate)) {
return;
}
jdbcTemplate.execute( jdbcTemplate.execute(
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0" "ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
); );
@@ -31,7 +31,9 @@ public class WebConfig implements WebMvcConfigurer {
"http://172.29.*.*:[*]", "http://172.29.*.*:[*]",
"http://172.30.*.*:[*]", "http://172.30.*.*:[*]",
"http://172.31.*.*:[*]", "http://172.31.*.*:[*]",
"http://*.local:[*]") "http://*.local:[*]",
"https://polskalokalnie.pl",
"https://www.polskalokalnie.pl")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*"); .allowedHeaders("*");
} }
@@ -94,7 +94,72 @@ public class GusService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON."); "Nie znaleziono firmy o podanym numerze NIP w rejestrze REGON.");
} }
return new GusCompanyResponse(dane.get("Nazwa"), nip, dane.get("Regon"), buildAddress(dane));
String name = dane.get("Nazwa");
String regon = dane.get("Regon");
String address = buildAddress(dane);
// Wyszukiwanie (DaneSzukajPodmioty) zwraca skrocony rekord - dla czesci podmiotow (czesto
// jednoosobowych dzialalnosci) nazwa lub adres sa puste i sa dostepne dopiero w pelnym
// raporcie. Dociagamy go tylko wtedy, gdy czegos brakuje. Blad raportu nie moze zepsuc
// dzialajacych przypadkow, wiec zostajemy przy danych z wyszukiwania.
if ((isBlank(name) || isBlank(address)) && !isBlank(regon)) {
try {
Map<String, String> full = fullReport(endpoint, key, regon, dane.get("Typ"));
if (isBlank(name)) {
name = full.get("Nazwa");
}
if (isBlank(address)) {
String fullAddress = buildAddress(full);
if (!isBlank(fullAddress)) {
address = fullAddress;
}
}
} catch (ResponseStatusException ignored) {
// Pelny raport jest tylko uzupelnieniem - brak odpowiedzi nie przerywa rejestracji.
}
}
return new GusCompanyResponse(name, nip, regon, address);
}
/**
* Pelny raport podmiotu (DanePobierzPelnyRaport) - uzupelnia nazwe i adres, gdy nie ma ich w
* wyniku wyszukiwania. Nazwa raportu i nazwy pol zaleza od typu podmiotu (osoba prawna vs fizyczna).
* Zwrocone pola mapujemy na te same klucze co wyszukiwanie, zeby uzyc wspolnego buildAddress.
*/
private Map<String, String> fullReport(String endpoint, String key, String regon, String typ) {
boolean legal = typ != null && typ.trim().toUpperCase().startsWith("P");
String report = legal ? "BIR11OsPrawna" : "BIR11OsFizycznaDaneOgolne";
String prefix = legal ? "praw_" : "fiz_";
String body = """
<ns:DanePobierzPelnyRaport>
<ns:pRegon>%s</ns:pRegon>
<ns:pNazwaRaportu>%s</ns:pNazwaRaportu>
</ns:DanePobierzPelnyRaport>""".formatted(regon, report);
String response = call(endpoint, "DanePobierzPelnyRaport", body, session(endpoint, key));
Map<String, String> raw = firstDane(resultOf(response, "DanePobierzPelnyRaportResult"));
if (raw == null) {
return Map.of();
}
Map<String, String> mapped = new HashMap<>();
putIfPresent(mapped, "Nazwa", raw.get(prefix + "nazwa"));
putIfPresent(mapped, "Ulica", raw.get(prefix + "adSiedzUlica_Nazwa"));
putIfPresent(mapped, "NrNieruchomosci", raw.get(prefix + "adSiedzNumerNieruchomosci"));
putIfPresent(mapped, "NrLokalu", raw.get(prefix + "adSiedzNumerLokalu"));
putIfPresent(mapped, "KodPocztowy", raw.get(prefix + "adSiedzKodPocztowy"));
putIfPresent(mapped, "Miejscowosc", raw.get(prefix + "adSiedzMiejscowosc_Nazwa"));
return mapped;
}
private static void putIfPresent(Map<String, String> target, String key, String value) {
if (!isBlank(value)) {
target.put(key, value);
}
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
} }
// Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze. // Pusty wynik oznacza wygasla sesje - wtedy logujemy sie ponownie i probujemy raz jeszcze.
@@ -1,132 +0,0 @@
package pl.polskalokalnie.i18n;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.user.PreferredLanguage;
@Service
public class RuntimeTranslationService {
private static final int MAX_TEXTS_PER_REQUEST = 180;
private static final int MAX_TEXT_LENGTH = 400;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, String> cache = new ConcurrentHashMap<>();
public Map<String, String> translateBatch(PreferredLanguage language, List<String> texts) {
if (texts == null || texts.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia");
}
if (texts.size() > MAX_TEXTS_PER_REQUEST) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu");
}
if (language == PreferredLanguage.PL) {
Map<String, String> passthrough = new LinkedHashMap<>();
texts.forEach((text) -> passthrough.put(text, text));
return passthrough;
}
String targetCode = toGoogleLang(language);
Map<String, String> results = new LinkedHashMap<>();
List<String> toTranslate = new ArrayList<>();
for (String text : texts) {
String safeText = text != null ? text : "";
if (safeText.length() > MAX_TEXT_LENGTH) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia");
}
String cacheKey = targetCode + "|" + safeText;
String cached = cache.get(cacheKey);
if (cached != null) {
results.put(safeText, cached);
} else {
toTranslate.add(safeText);
}
}
for (String text : toTranslate) {
String translated = translateSingle(text, targetCode);
cache.put(targetCode + "|" + text, translated);
results.put(text, translated);
}
return results;
}
private String translateSingle(String source, String targetCode) {
if (source.isBlank()) {
return source;
}
String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8);
String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl="
+ targetCode + "&dt=t&q=" + encoded;
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.header("User-Agent", "PolskaLokalnie/1.0")
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna");
}
return parseTranslatedText(response.body(), source);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
} catch (IOException ex) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
}
}
private String parseTranslatedText(String payload, String fallback) throws IOException {
JsonNode root = objectMapper.readTree(payload);
JsonNode topSegments = root.path(0);
if (!topSegments.isArray() || topSegments.isEmpty()) {
return fallback;
}
StringBuilder translated = new StringBuilder();
for (JsonNode segment : topSegments) {
JsonNode part = segment.path(0);
if (part.isTextual()) {
translated.append(part.asText());
}
}
String value = translated.toString();
return value.isBlank() ? fallback : value;
}
private String toGoogleLang(PreferredLanguage language) {
return switch (language) {
case EN -> "en";
case UK -> "uk";
case DE -> "de";
case PL -> "pl";
};
}
}
@@ -1,12 +0,0 @@
package pl.polskalokalnie.i18n;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import pl.polskalokalnie.user.PreferredLanguage;
public record TranslateRequest(
@NotNull PreferredLanguage language,
@NotEmpty List<String> texts
) {
}
@@ -1,10 +0,0 @@
package pl.polskalokalnie.i18n;
import java.util.Map;
import pl.polskalokalnie.user.PreferredLanguage;
public record TranslateResponse(
PreferredLanguage language,
Map<String, String> translations
) {
}
@@ -1,31 +0,0 @@
package pl.polskalokalnie.i18n;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/i18n")
public class TranslationController {
private final RuntimeTranslationService runtimeTranslationService;
public TranslationController(RuntimeTranslationService runtimeTranslationService) {
this.runtimeTranslationService = runtimeTranslationService;
}
@PostMapping("/translate")
public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) {
List<String> normalizedTexts = request.texts().stream()
.map(text -> text == null ? "" : text)
.distinct()
.collect(Collectors.toList());
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
return new TranslateResponse(request.language(), translations);
}
}
@@ -67,13 +67,17 @@ public class Lead {
@Column(name = "tag", length = 60) @Column(name = "tag", length = 60)
private List<String> tags = new ArrayList<>(); private List<String> tags = new ArrayList<>();
// Zgoda marketingowa musi byc wyrazna i uprzednia (art. 4 pkt 11 i art. 7 RODO oraz Prawo
// komunikacji elektronicznej), wiec domyslnie jej NIE MA. Wlacza ja sam uzytkownik
// w ustawieniach konta (MarketingConsentController) albo administrator przy imporcie leada,
// majac podstawe zebrana poza serwisem.
@Column(nullable = false) @Column(nullable = false)
@ColumnDefault("true") @ColumnDefault("false")
private boolean emailConsent = true; private boolean emailConsent = false;
@Column(nullable = false) @Column(nullable = false)
@ColumnDefault("true") @ColumnDefault("false")
private boolean smsConsent = true; private boolean smsConsent = false;
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje. // Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
@Column(nullable = false) @Column(nullable = false)
@@ -231,7 +231,11 @@ public class LeadService {
lead.setPhone(phone); lead.setPhone(phone);
} }
} }
lead.setName(trimToNull(request.fullName())); // Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
if (lead.getId() == null || lead.getName() == null) {
lead.setName(trimToNull(request.fullName()));
}
List<String> tags = new ArrayList<>(lead.getTags()); List<String> tags = new ArrayList<>(lead.getTags());
tags.add(ADVERTISING_TAG); tags.add(ADVERTISING_TAG);
@@ -239,9 +243,12 @@ public class LeadService {
lead.setTags(cleanTags(tags)); lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc // Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu. // telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
lead.setEmailConsent(consent); // e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
lead.setSmsConsent(consent); if (lead.getId() == null) {
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
}
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone)); lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead); leadRepository.save(lead);
} }
@@ -1,5 +1,6 @@
package pl.polskalokalnie.listing; package pl.polskalokalnie.listing;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
public class ListingController { public class ListingController {
private final ListingService listingService; private final ListingService listingService;
private final ListingViewGuard viewGuard;
public ListingController(ListingService listingService) { public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
this.listingService = listingService; this.listingService = listingService;
this.viewGuard = viewGuard;
} }
/** /**
@@ -40,6 +43,12 @@ public class ListingController {
return listingService.search(city, offerType, propertyType, directOnly); return listingService.search(city, offerType, propertyType, directOnly);
} }
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
@GetMapping("/{id}/seller-listings")
public List<ListingResponse> sellerListings(@PathVariable Long id) {
return listingService.sellerListings(id);
}
@GetMapping("/mine") @GetMapping("/mine")
public List<ListingResponse> mine(Authentication authentication) { public List<ListingResponse> mine(Authentication authentication) {
return listingService.findMine(authentication.getName()); return listingService.findMine(authentication.getName());
@@ -50,12 +59,16 @@ public class ListingController {
public ListingDetailResponse getById( public ListingDetailResponse getById(
@PathVariable Long id, @PathVariable Long id,
@RequestParam(defaultValue = "false") boolean incrementView, @RequestParam(defaultValue = "false") boolean incrementView,
Authentication authentication Authentication authentication,
HttpServletRequest request
) { ) {
String viewerEmail = authentication == null ? null : authentication.getName(); String viewerEmail = authentication == null ? null : authentication.getName();
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream() boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin); // Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
} }
@PostMapping @PostMapping
@@ -69,7 +82,7 @@ public class ListingController {
.body(response); .body(response);
} }
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian). // Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
@PutMapping("/{id}") @PutMapping("/{id}")
public ListingDetailResponse update( public ListingDetailResponse update(
@PathVariable Long id, @PathVariable Long id,
@@ -51,6 +51,15 @@ public record ListingDetailResponse(
Long ownerId, Long ownerId,
// Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly. // Zdjecie profilowe wlasciciela (data URL) albo null - wtedy karta sprzedajacego rysuje inicjaly.
String ownerAvatar, String ownerAvatar,
// Typ konta wlasciciela ("PERSONAL"/"COMPANY") albo null, gdy ogloszenie nie ma konta w serwisie.
// Po tym front rozroznia oferte prywatna od firmowej (etykieta na karcie, jak na Otodom).
String ownerAccountType,
// Nazwa wlasciciela do etykiety oferty firmowej (dla konta firmowego). Dla oferty prywatnej pomijana.
String ownerName,
// Data zalozenia konta wlasciciela - na karcie sprzedajacego pokazujemy "Na Polska Lokalnie od...".
Instant ownerCreatedAt,
// Ostatnia aktywnosc wlasciciela - na karcie sprzedajacego "Ostatnio aktywny/a...". Null = brak konta.
Instant ownerLastActiveAt,
ListingStatus status, ListingStatus status,
Instant createdAt, Instant createdAt,
Long viewsCount, Long viewsCount,
@@ -63,16 +72,22 @@ public record ListingDetailResponse(
private static final double METERS_PER_DEGREE = 111_320d; private static final double METERS_PER_DEGREE = 111_320d;
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) { public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return from(listing, ownerId, ownerAvatar, false); return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
} }
/** /**
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz * Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa * wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej * zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu. * wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
*
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
*/ */
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar, boolean masked) { public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
boolean masked, boolean publicView) {
String street = masked ? null : listing.getStreet(); String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding(); String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress(); String address = masked ? approximateAddress(listing) : listing.getAddress();
@@ -100,7 +115,7 @@ public record ListingDetailResponse(
listing.getOwnership(), listing.getOwnership(),
listing.getContactName(), listing.getContactName(),
listing.getContactPhone(), listing.getContactPhone(),
listing.getContactEmail(), publicView ? null : listing.getContactEmail(),
listing.getRentExtra(), listing.getRentExtra(),
listing.getAvailableFrom(), listing.getAvailableFrom(),
listing.getFurnishing(), listing.getFurnishing(),
@@ -115,9 +130,13 @@ public record ListingDetailResponse(
List.copyOf(listing.getAmenities()), List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()), List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(), listing.getVirtualTourUrl(),
listing.getOwnerEmail(), publicView ? null : listing.getOwnerEmail(),
ownerId, ownerId,
ownerAvatar, ownerAvatar,
ownerAccountType,
ownerName,
ownerCreatedAt,
ownerLastActiveAt,
listing.getStatus(), listing.getStatus(),
listing.getCreatedAt(), listing.getCreatedAt(),
listing.getViewsCount(), listing.getViewsCount(),
@@ -132,31 +151,33 @@ public record ListingDetailResponse(
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity(); return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
} }
private static Double blurLat(PropertyListing listing) { // Zamiast przesuniecia liczonego z (jawnego) identyfikatora ogloszenia - ktore dalo sie odwrocic
// i odzyskac dokladny adres - punkt jest przyciagany do srodka kratki siatki. Z odpowiedzi wiadomo
// tylko, w ktorej kratce lezy adres. Kratka ma bok rowny promieniowi kola, wiec najdalszy jej rog
// (~0,71 promienia od srodka) zawsze miesci sie w rysowanym obszarze, a punkt nie skacze po mapie.
private static final double GRID_CELL_M = APPROXIMATE_RADIUS_M;
static Double blurLat(PropertyListing listing) {
if (listing.getLat() == null) { if (listing.getLat() == null) {
return null; return null;
} }
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE; return snapToCellCenter(listing.getLat(), GRID_CELL_M / METERS_PER_DEGREE);
} }
private static Double blurLng(PropertyListing listing) { static Double blurLng(PropertyListing listing) {
if (listing.getLng() == null || listing.getLat() == null) { if (listing.getLng() == null || listing.getLat() == null) {
return null; return null;
} }
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat())); // Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
if (parallel == 0) { // dokladnej szerokosci adresu - inaczej sam rozmiar kratki zdradzalby dokladne lat.
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(blurLat(listing)));
if (parallel <= 0) {
return listing.getLng(); return listing.getLng();
} }
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel; return snapToCellCenter(listing.getLng(), GRID_CELL_M / parallel);
} }
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia, private static double snapToCellCenter(double value, double cellDegrees) {
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola. return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
private static double offsetAngle(PropertyListing listing) {
return Math.toRadians((listing.getId() * 137L) % 360);
}
private static double offsetMeters(PropertyListing listing) {
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
} }
} }
@@ -17,8 +17,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
/** /**
* Zapisywanie ogloszen do ulubionych. Frontend trzyma liste ulubionych lokalnie (szybkie UI), * Ulubione ogloszenia uzytkownika - trzymane na koncie, wiec sa te same na kazdym urzadzeniu.
* a tutaj rejestrujemy sam fakt zapisania - dzieki temu wlasciciel widzi, ile osob zapisalo oferte. * Z tych samych danych liczymy wlascicielowi, ile osob zapisalo jego oferte.
*/ */
@RestController @RestController
@RequestMapping("/api/listings") @RequestMapping("/api/listings")
@@ -49,30 +49,38 @@ public class ListingFavoriteController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** /** Ulubione zalogowanego uzytkownika - te same na kazdym urzadzeniu. */
* Ustawia ulubione uzytkownika dokladnie na przeslana liste. Front trzyma ulubione lokalnie @GetMapping("/favorites")
* i po kazdej zmianie wysyla caly zbior - dzieki temu serwer zna stan niezaleznie od tego, public List<ListingResponse> myFavorites(Authentication authentication) {
* z ktorego miejsca w aplikacji uzytkownik dodal lub usunal ogloszenie. List<Long> listingIds = favoriteRepository.findByUserEmailIgnoreCase(authentication.getName()).stream()
*/ .map(ListingFavorite::getListingId)
@PostMapping("/favorites/sync")
@Transactional
public ResponseEntity<Void> sync(@RequestBody SyncRequest request, Authentication authentication) {
if (request == null || request.listingIds() == null) {
return ResponseEntity.noContent().build();
}
String userEmail = authentication.getName();
List<Long> wanted = request.listingIds().stream()
.filter(java.util.Objects::nonNull)
.distinct()
.filter(listingRepository::existsById)
.toList(); .toList();
if (listingIds.isEmpty()) {
return List.of();
}
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
return listingRepository.findAllById(listingIds).stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(ListingResponse::publicFrom)
.toList();
}
favoriteRepository.findByUserEmailIgnoreCase(userEmail).stream() /**
.filter(favorite -> !wanted.contains(favorite.getListingId())) * Dopisanie ulubionych zapisanych wczesniej w przegladarce. Tylko dodaje - nigdy nie kasuje,
.forEach(favoriteRepository::delete); * zeby wejscie z pustej przegladarki nie wyczyscilo listy zebranej na innym urzadzeniu.
*/
wanted.forEach(listingId -> save(listingId, userEmail)); @PostMapping("/favorites/import")
return ResponseEntity.noContent().build(); @Transactional
public List<ListingResponse> importLocal(@RequestBody SyncRequest request, Authentication authentication) {
if (request != null && request.listingIds() != null) {
String userEmail = authentication.getName();
request.listingIds().stream()
.filter(java.util.Objects::nonNull)
.distinct()
.filter(listingRepository::existsById)
.forEach(listingId -> save(listingId, userEmail));
}
return myFavorites(authentication);
} }
/** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */ /** Liczba zapisan dla ogloszen zalogowanego uzytkownika: {listingId: liczba}. */
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingRepository extends JpaRepository<PropertyListing, Long> { public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status); List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
} }
@@ -30,15 +30,20 @@ public record ListingResponse(
Instant promotedUntil, Instant promotedUntil,
Instant createdAt Instant createdAt
) { ) {
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
public static ListingResponse from(PropertyListing listing) { public static ListingResponse from(PropertyListing listing) {
return from(listing, false); return build(listing, false, listing.getOwnerEmail());
} }
/** /**
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja * Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
* ogladanych spoza panelu wlasciciela i administracji. * adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
*/ */
public static ListingResponse from(PropertyListing listing, boolean masked) { public static ListingResponse publicFrom(PropertyListing listing) {
return build(listing, listing.isApproximateLocation(), null);
}
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
String district = listing.getDistrict(); String district = listing.getDistrict();
String address = masked String address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity()) ? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
@@ -60,7 +65,7 @@ public record ListingResponse(
listing.getMarket(), listing.getMarket(),
listing.getYearBuilt(), listing.getYearBuilt(),
listing.getCoverPhoto(), listing.getCoverPhoto(),
listing.getOwnerEmail(), ownerEmail,
listing.getStatus(), listing.getStatus(),
listing.getViewsCount(), listing.getViewsCount(),
listing.getPromotedUntil(), listing.getPromotedUntil(),
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService; import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService; import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.search.SavedSearchNotifier;
import pl.polskalokalnie.user.AccountType; import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserRepository;
@@ -28,15 +29,17 @@ public class ListingService {
private final NotificationService notificationService; private final NotificationService notificationService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserSettingsService userSettingsService; private final UserSettingsService userSettingsService;
private final SavedSearchNotifier savedSearchNotifier;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService, public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService, UserRepository userRepository, NotificationService notificationService, UserRepository userRepository,
UserSettingsService userSettingsService) { UserSettingsService userSettingsService, SavedSearchNotifier savedSearchNotifier) {
this.listingRepository = listingRepository; this.listingRepository = listingRepository;
this.textModerationService = textModerationService; this.textModerationService = textModerationService;
this.notificationService = notificationService; this.notificationService = notificationService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userSettingsService = userSettingsService; this.userSettingsService = userSettingsService;
this.savedSearchNotifier = savedSearchNotifier;
} }
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow. // Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
@@ -52,19 +55,22 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste. * Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/ */
private ListingDetailResponse detailOf(PropertyListing listing) { private ListingDetailResponse detailOf(PropertyListing listing) {
return detailOf(listing, false); return detailOf(listing, false, false);
} }
/** /**
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych * @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji. * wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
*/ */
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) { private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
AppUser owner = ownerOf(listing); AppUser owner = ownerOf(listing);
if (owner == null) { if (owner == null) {
return ListingDetailResponse.from(listing, null, null, masked); return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
} }
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()), masked); String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
} }
/** /**
@@ -95,7 +101,7 @@ public class ListingService {
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType) .filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing)) .filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst()) .sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation())) .map(ListingResponse::publicFrom)
.toList(); .toList();
} }
@@ -132,8 +138,29 @@ public class ListingService {
listing = listingRepository.save(listing); listing = listingRepository.save(listing);
} }
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje. // Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin); // lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
boolean publicView = !isOwner && !viewerIsAdmin;
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
}
/**
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
*/
public List<ListingResponse> sellerListings(Long listingId) {
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return List.of();
}
return listingRepository
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
.stream()
.filter(other -> !other.getId().equals(listingId))
.limit(6)
.map(ListingResponse::publicFrom)
.toList();
} }
public List<ListingResponse> findMine(String ownerEmail) { public List<ListingResponse> findMine(String ownerEmail) {
@@ -159,7 +186,11 @@ public class ListingService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic"); throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
} }
listing.setStatus(status); listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing)); PropertyListing saved = listingRepository.save(listing);
if (status == ListingStatus.APPROVED) {
savedSearchNotifier.notifyMatching(saved);
}
return ListingResponse.from(saved);
} }
@Transactional @Transactional
@@ -192,8 +223,8 @@ public class ListingService {
return detailOf(saved); return detailOf(saved);
} }
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu - // Edycja wlasnego ogloszenia. Waliduje tresc filtrem; istotna zmiana tresci odsyla ogloszenie
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian. // do ponownej weryfikacji, a drobne zmiany (cena, metraz, parametry) sa widoczne od razu.
@Transactional @Transactional
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) { public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
PropertyListing listing = listingRepository.findById(id) PropertyListing listing = listingRepository.findById(id)
@@ -204,13 +235,27 @@ public class ListingService {
request.title(), request.description(), request.city(), request.address(), request.title(), request.description(), request.city(), request.address(),
request.district(), request.street(), request.contactName()); request.district(), request.street(), request.contactName());
List<Object> before = moderatedContent(listing);
applyRequest(listing, request); applyRequest(listing, request);
// ownerEmail i status pozostaja bez zmian. // Zmiana tresci widocznej dla kupujacych (tytul, opis, zdjecia, kontakt, link spaceru) wraca do
// weryfikacji - inaczej zatwierdzone ogloszenie mozna bylo podmienic na dowolne, np. oszustwo.
// Dotyczy tez wstrzymanego, bo wlasciciel sam je wznawia. Cena, metraz itp. zmieniaja sie od razu.
boolean visible = listing.getStatus() == ListingStatus.APPROVED || listing.getStatus() == ListingStatus.PAUSED;
if (visible && !before.equals(moderatedContent(listing))) {
listing.setStatus(ListingStatus.PENDING);
}
PropertyListing saved = listingRepository.save(listing); PropertyListing saved = listingRepository.save(listing);
return detailOf(saved); return detailOf(saved);
} }
// Pola, ktorych zmiana wymaga ponownej akceptacji administracji.
static List<Object> moderatedContent(PropertyListing listing) {
return java.util.Arrays.asList(listing.getTitle(), listing.getDescription(), List.copyOf(listing.getPhotos()),
listing.getContactName(), listing.getContactPhone(), listing.getContactEmail(),
listing.getVirtualTourUrl());
}
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn). // Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
private void applyRequest(PropertyListing listing, ListingCreateRequest request) { private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
listing.setTitle(request.title().trim()); listing.setTitle(request.title().trim());
@@ -271,6 +316,11 @@ public class ListingService {
listing.setStatus(status); listing.setStatus(status);
PropertyListing saved = listingRepository.save(listing); PropertyListing saved = listingRepository.save(listing);
notifyOwnerStatusChange(saved, status); notifyOwnerStatusChange(saved, status);
if (status == ListingStatus.APPROVED) {
// Ogloszenie wlasnie stalo sie widoczne - szukajacy z pasujacym zapisanym
// wyszukiwaniem dostaja powiadomienie na liscie w serwisie.
savedSearchNotifier.notifyMatching(saved);
}
return ListingResponse.from(saved); return ListingResponse.from(saved);
} }
@@ -304,6 +354,17 @@ public class ListingService {
listingRepository.deleteById(id); listingRepository.deleteById(id);
} }
/**
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
*/
@Transactional
public void deleteAllOfOwner(String ownerEmail) {
if (ownerEmail != null && !ownerEmail.isBlank()) {
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
}
}
private static String trimToNull(String value) { private static String trimToNull(String value) {
if (value == null) { if (value == null) {
return null; return null;
@@ -325,11 +386,20 @@ public class ListingService {
return cleaned; return cleaned;
} }
private static List<String> limitPhotos(List<String> photos) { // Zdjecia przyjmujemy wylacznie jako data URL obrazu rastrowego. Inny typ (np. text/html,
// image/svg+xml) serwowany potem z domeny serwisu pozwalalby uruchomic skrypt u oglądającego.
private static final java.util.regex.Pattern PHOTO_DATA_URL =
java.util.regex.Pattern.compile("^data:image/(jpeg|png|webp);base64,[A-Za-z0-9+/=\\s]+$");
static List<String> limitPhotos(List<String> photos) {
List<String> result = new ArrayList<>(); List<String> result = new ArrayList<>();
if (photos != null) { if (photos != null) {
for (String photo : photos) { for (String photo : photos) {
if (photo != null && !photo.isBlank()) { if (photo != null && !photo.isBlank()) {
if (!PHOTO_DATA_URL.matcher(photo).matches()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Nieprawidłowy format zdjęcia - dozwolone są obrazy JPEG, PNG i WebP");
}
result.add(photo); result.add(photo);
if (result.size() >= MAX_PHOTOS) { if (result.size() >= MAX_PHOTOS) {
break; break;
@@ -0,0 +1,39 @@
package pl.polskalokalnie.listing;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
*/
@Component
public class ListingViewGuard {
private static final Duration WINDOW = Duration.ofHours(1);
private static final int MAX_ENTRIES = 100_000;
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
public boolean shouldCount(Long listingId, String viewer) {
Instant now = Instant.now();
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.clear();
}
}
String key = listingId + "|" + (viewer == null ? "" : viewer);
Instant previous = lastCounted.get(key);
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
return false;
}
lastCounted.put(key, now);
return true;
}
}
@@ -0,0 +1,85 @@
package pl.polskalokalnie.market;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna,
* czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu.
*
* DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki
* lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym
* pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen
* mieszkan, ktory juz pobieramy w module historii cen.
*/
@Component
public class EurostatRateClient {
static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m"
+ "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6";
/** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */
public record Rate(BigDecimal percent, String period) {
}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private final ObjectMapper mapper = new ObjectMapper();
public Rate latestThreeMonthRate() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new IOException("Eurostat odpowiedzial " + response.statusCode());
}
return parseLatest(mapper.readTree(response.body()));
}
/**
* Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar
* czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem
* indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time".
*/
static Rate parseLatest(JsonNode root) throws IOException {
JsonNode values = root.path("value");
JsonNode periods = root.path("dimension").path("time").path("category").path("index");
if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) {
throw new IOException("Eurostat: odpowiedz bez danych");
}
// Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste.
Map.Entry<String, JsonNode> latest = null;
for (var it = periods.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|| Comparator.<String>naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) {
latest = entry;
}
}
if (latest == null) {
throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca");
}
BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue();
if (percent.signum() <= 0) {
throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent);
}
return new Rate(percent, latest.getKey());
}
}
@@ -0,0 +1,92 @@
package pl.polskalokalnie.market;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/**
* Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku
* pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem
* pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje.
*/
@Entity
@Table(name = "market_indicators")
public class MarketIndicator {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "indicator_key", nullable = false, unique = true, length = 40)
private String key;
@Column(nullable = false, precision = 12, scale = 4)
private BigDecimal value;
/** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */
@Column(name = "as_of", nullable = false, length = 20)
private String asOf;
/** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */
@Column(nullable = false, length = 120)
private String source;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
protected MarketIndicator() {
}
public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
this.key = key;
this.value = value;
this.asOf = asOf;
this.source = source;
this.fetchedAt = fetchedAt;
}
public Long getId() {
return id;
}
public String getKey() {
return key;
}
public BigDecimal getValue() {
return value;
}
public void setValue(BigDecimal value) {
this.value = value;
}
public String getAsOf() {
return asOf;
}
public void setAsOf(String asOf) {
this.asOf = asOf;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public Instant getFetchedAt() {
return fetchedAt;
}
public void setFetchedAt(Instant fetchedAt) {
this.fetchedAt = fetchedAt;
}
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.market;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator.
* Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach.
*/
@RestController
@RequestMapping("/api/market-indicators")
public class MarketIndicatorController {
public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
}
private final MarketIndicatorService service;
public MarketIndicatorController(MarketIndicatorService service) {
this.service = service;
}
@GetMapping
public List<IndicatorResponse> list() {
return service.all().stream()
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
.toList();
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.market;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
Optional<MarketIndicator> findByKey(String key);
}
@@ -0,0 +1,119 @@
package pl.polskalokalnie.market;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.pricehistory.GusBdlClient;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny
* transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej
* jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie.
*/
@Service
public class MarketIndicatorService {
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */
public static final String AVERAGE_WAGE = "average-wage-gross";
/** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */
public static final String MONEY_MARKET_3M = "money-market-3m";
private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class);
private final MarketIndicatorRepository repository;
private final GusBdlClient gusClient;
private final EurostatRateClient eurostatClient;
public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient,
EurostatRateClient eurostatClient) {
this.repository = repository;
this.gusClient = gusClient;
this.eurostatClient = eurostatClient;
}
@Transactional(readOnly = true)
public List<MarketIndicator> all() {
return repository.findAll();
}
@Async
@EventListener(ApplicationReadyEvent.class)
public void refreshOnStartupIfStale() {
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
if (isStale(AVERAGE_WAGE, weekAgo)) {
refreshAverageWage();
}
if (isStale(MONEY_MARKET_3M, weekAgo)) {
refreshMoneyMarketRate();
}
}
@Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw")
public void refreshWeekly() {
refreshAverageWage();
refreshMoneyMarketRate();
}
public boolean refreshAverageWage() {
try {
GusBdlClient.AnnualValue wage = gusClient.latestAverageWage();
save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych");
log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
public boolean refreshMoneyMarketRate() {
try {
EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate();
save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)");
log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
private boolean isStale(String key, Instant threshold) {
return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true);
}
@Transactional
protected void save(String key, BigDecimal value, String asOf, String source) {
Optional<MarketIndicator> existing = repository.findByKey(key);
Instant now = Instant.now();
if (existing.isPresent()) {
MarketIndicator indicator = existing.get();
indicator.setValue(value);
indicator.setAsOf(asOf);
indicator.setSource(source);
indicator.setFetchedAt(now);
repository.save(indicator);
} else {
repository.save(new MarketIndicator(key, value, asOf, source, now));
}
}
}
@@ -0,0 +1,222 @@
package pl.polskalokalnie.meeting;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* Spotkanie (ogladanie nieruchomosci) albo wlasny wpis w kalendarzu konta. Wczesniej kalendarz
* zyl w localStorage jednej przegladarki - druga strona nic o nim nie wiedziala.
*
* <p>Wpis prywatny ma puste conversationId i counterpartId oraz status CONFIRMED - nikt poza
* autorem go nie widzi. Spotkanie umowione z druga strona powstaje z rozmowy o ogloszeniu
* i jest widoczne dla obu uczestnikow.</p>
*/
@Entity
@Table(name = "meetings")
public class Meeting {
public enum Status {
/** Termin zaproponowany, czeka na odpowiedz drugiej strony. */
PROPOSED,
/** Termin potwierdzony przez druga strone. */
CONFIRMED,
/** Druga strona odrzucila termin. */
DECLINED,
/** Odwolane - moze to zrobic kazda ze stron, do konca spotkania. */
CANCELLED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "conversation_id")
private Long conversationId;
@Column(name = "listing_id")
private Long listingId;
@Column(name = "listing_title", length = 200)
private String listingTitle;
/** Kto zaproponowal termin (albo autor wpisu prywatnego). */
@Column(name = "proposer_id", nullable = false)
private Long proposerId;
/** Druga strona spotkania; null dla wpisu prywatnego. */
@Column(name = "counterpart_id")
private Long counterpartId;
@Column(nullable = false, length = 160)
private String title;
@Column(length = 200)
private String note;
@Column(length = 200)
private String location;
@Column(name = "meeting_date", nullable = false)
private LocalDate meetingDate;
@Column(name = "start_time", nullable = false)
private LocalTime startTime;
@Column(name = "end_time", nullable = false)
private LocalTime endTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Status status = Status.PROPOSED;
/** Kto odwolal spotkanie - druga strona widzi, po czyjej stronie byla zmiana. */
@Column(name = "cancelled_by_id")
private Long cancelledById;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
@Column(name = "updated_at", nullable = false)
private Instant updatedAt = Instant.now();
public boolean isPrivateNote() {
return counterpartId == null;
}
public boolean isParticipant(Long userId) {
return proposerId.equals(userId) || (counterpartId != null && counterpartId.equals(userId));
}
public Long otherParticipant(Long userId) {
return proposerId.equals(userId) ? counterpartId : proposerId;
}
public Long getId() {
return id;
}
public Long getConversationId() {
return conversationId;
}
public void setConversationId(Long conversationId) {
this.conversationId = conversationId;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getListingTitle() {
return listingTitle;
}
public void setListingTitle(String listingTitle) {
this.listingTitle = listingTitle;
}
public Long getProposerId() {
return proposerId;
}
public void setProposerId(Long proposerId) {
this.proposerId = proposerId;
}
public Long getCounterpartId() {
return counterpartId;
}
public void setCounterpartId(Long counterpartId) {
this.counterpartId = counterpartId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getNote() {
return note;
}
public void setNote(String note) {
this.note = note;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public LocalDate getMeetingDate() {
return meetingDate;
}
public void setMeetingDate(LocalDate meetingDate) {
this.meetingDate = meetingDate;
}
public LocalTime getStartTime() {
return startTime;
}
public void setStartTime(LocalTime startTime) {
this.startTime = startTime;
}
public LocalTime getEndTime() {
return endTime;
}
public void setEndTime(LocalTime endTime) {
this.endTime = endTime;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public Long getCancelledById() {
return cancelledById;
}
public void setCancelledById(Long cancelledById) {
this.cancelledById = cancelledById;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,69 @@
package pl.polskalokalnie.meeting;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/** Kalendarz spotkan zalogowanego uzytkownika - wlasne wpisy i ogladania umowione z druga strona. */
@RestController
@RequestMapping("/api/meetings")
public class MeetingController {
private final MeetingService meetingService;
private final UserRepository userRepository;
public MeetingController(MeetingService meetingService, UserRepository userRepository) {
this.meetingService = meetingService;
this.userRepository = userRepository;
}
@GetMapping
public List<MeetingResponse> list(Authentication authentication) {
return meetingService.list(me(authentication));
}
@PostMapping
public MeetingResponse create(@RequestBody MeetingRequest request, Authentication authentication) {
return meetingService.create(me(authentication), request);
}
@PutMapping("/{id}")
public MeetingResponse update(@PathVariable Long id, @RequestBody MeetingRequest request, Authentication authentication) {
return meetingService.updatePrivate(me(authentication), id, request);
}
@PostMapping("/{id}/confirm")
public MeetingResponse confirm(@PathVariable Long id, Authentication authentication) {
return meetingService.respond(me(authentication), id, true);
}
@PostMapping("/{id}/decline")
public MeetingResponse decline(@PathVariable Long id, Authentication authentication) {
return meetingService.respond(me(authentication), id, false);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancel(@PathVariable Long id, Authentication authentication) {
meetingService.cancel(me(authentication), id);
return ResponseEntity.noContent().build();
}
private AppUser me(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Zaloguj się ponownie"));
}
}
@@ -0,0 +1,29 @@
package pl.polskalokalnie.meeting;
import java.time.LocalDate;
import java.time.LocalTime;
public final class MeetingDtos {
private MeetingDtos() {
}
/**
* Propozycja terminu albo wlasny wpis w kalendarzu. Z conversationId powstaje spotkanie
* z druga strona rozmowy; bez niego - wpis prywatny, widoczny tylko dla autora.
*/
public record MeetingRequest(Long conversationId, String title, String note, String location,
LocalDate date, LocalTime start, LocalTime end) {
}
/**
* status: PROPOSED / CONFIRMED / DECLINED / CANCELLED.
* proposedByMe - czy to ja zaproponowalem termin (tylko druga strona moze go potwierdzic).
* privateNote - wpis wlasny, bez drugiej strony.
*/
public record MeetingResponse(Long id, Long conversationId, Long listingId, String listingTitle,
String title, String note, String location, LocalDate date, LocalTime start,
LocalTime end, String status, boolean proposedByMe, boolean privateNote,
String otherName, boolean cancelledByMe) {
}
}
@@ -0,0 +1,13 @@
package pl.polskalokalnie.meeting;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface MeetingRepository extends JpaRepository<Meeting, Long> {
@Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId"
+ " order by m.meetingDate asc, m.startTime asc")
List<Meeting> findForUser(@Param("userId") Long userId);
}
@@ -0,0 +1,291 @@
package pl.polskalokalnie.meeting;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.auth.AccountEmailService;
import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
import pl.polskalokalnie.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.ConversationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Umawianie ogladania nieruchomosci. Termin proponuje jedna ze stron rozmowy o ogloszeniu,
* druga potwierdza albo odrzuca; odwolac moze kazda ze stron, do konca spotkania.
*
* <p>Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail
* wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.</p>
*/
@Service
public class MeetingService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm");
private final MeetingRepository meetingRepository;
private final ConversationRepository conversationRepository;
private final ConversationService conversationService;
private final UserRepository userRepository;
private final UserSettingsRepository userSettingsRepository;
private final NotificationService notificationService;
private final AccountEmailService accountEmailService;
public MeetingService(MeetingRepository meetingRepository,
ConversationRepository conversationRepository,
ConversationService conversationService,
UserRepository userRepository,
UserSettingsRepository userSettingsRepository,
NotificationService notificationService,
AccountEmailService accountEmailService) {
this.meetingRepository = meetingRepository;
this.conversationRepository = conversationRepository;
this.conversationService = conversationService;
this.userRepository = userRepository;
this.userSettingsRepository = userSettingsRepository;
this.notificationService = notificationService;
this.accountEmailService = accountEmailService;
}
@Transactional(readOnly = true)
public List<MeetingResponse> list(AppUser me) {
List<Meeting> meetings = meetingRepository.findForUser(me.getId());
Map<Long, AppUser> others = userRepository.findAllById(meetings.stream()
.map(meeting -> meeting.otherParticipant(me.getId()))
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet()))
.stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
return meetings.stream().map(meeting -> toResponse(meeting, me, others.get(meeting.otherParticipant(me.getId())))).toList();
}
@Transactional
public MeetingResponse create(AppUser me, MeetingRequest request) {
validate(request);
Meeting meeting = new Meeting();
meeting.setProposerId(me.getId());
meeting.setTitle(request.title().trim());
meeting.setNote(trimOrNull(request.note()));
meeting.setLocation(trimOrNull(request.location()));
meeting.setMeetingDate(request.date());
meeting.setStartTime(request.start());
meeting.setEndTime(request.end());
if (request.conversationId() == null) {
// Wpis wlasny - nie ma z kim uzgadniac terminu, wiec od razu jest potwierdzony.
meeting.setStatus(Meeting.Status.CONFIRMED);
return toResponse(meetingRepository.save(meeting), me, null);
}
Conversation conversation = requireConversation(me, request.conversationId());
// Zablokowany uzytkownik nie moze pisac - propozycja spotkania to tez wiadomosc w rozmowie.
conversationService.requireCanWrite(conversation, me);
AppUser other = requireUser(conversation.otherParticipant(me.getId()));
meeting.setConversationId(conversation.getId());
meeting.setListingId(conversation.getListingId());
meeting.setListingTitle(conversation.getListingTitle());
meeting.setCounterpartId(other.getId());
meeting.setStatus(Meeting.Status.PROPOSED);
Meeting saved = meetingRepository.save(meeting);
String when = when(saved);
conversationService.appendMeetingNote(conversation, me,
"Propozycja oglądania: " + when + (saved.getLocation() == null ? "" : ", " + saved.getLocation()));
notify(other, "Propozycja oglądania", displayName(me) + " proponuje termin: " + when, conversation.getId());
return toResponse(saved, me, other);
}
/** Zmiana wlasnego wpisu w kalendarzu. Terminu umowionego z druga strona sie nie edytuje - odwoluje sie go i proponuje nowy. */
@Transactional
public MeetingResponse updatePrivate(AppUser me, Long meetingId, MeetingRequest request) {
validate(request);
Meeting meeting = requireParticipant(me, meetingId);
if (!meeting.isPrivateNote()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Umówionego terminu nie da się zmienić - odwołaj go i zaproponuj nowy");
}
meeting.setTitle(request.title().trim());
meeting.setNote(trimOrNull(request.note()));
meeting.setLocation(trimOrNull(request.location()));
meeting.setMeetingDate(request.date());
meeting.setStartTime(request.start());
meeting.setEndTime(request.end());
meeting.setUpdatedAt(Instant.now());
return toResponse(meeting, me, null);
}
/** Potwierdzenie albo odrzucenie terminu - tylko przez osobe, ktora go dostala. */
@Transactional
public MeetingResponse respond(AppUser me, Long meetingId, boolean accept) {
Meeting meeting = requireParticipant(me, meetingId);
if (meeting.isPrivateNote() || meeting.getProposerId().equals(me.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Termin potwierdza osoba, która go otrzymała");
}
if (meeting.getStatus() != Meeting.Status.PROPOSED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten termin został już rozpatrzony");
}
meeting.setStatus(accept ? Meeting.Status.CONFIRMED : Meeting.Status.DECLINED);
meeting.setUpdatedAt(Instant.now());
AppUser other = requireUser(meeting.getProposerId());
String when = when(meeting);
Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
if (conversation != null) {
conversationService.appendMeetingNote(conversation, me,
(accept ? "Potwierdzam oglądanie: " : "Nie pasuje mi termin: ") + when);
}
notify(other, accept ? "Termin potwierdzony" : "Termin odrzucony",
displayName(me) + (accept ? " potwierdza oglądanie: " : " nie może w terminie: ") + when,
meeting.getConversationId());
if (accept) {
sendConfirmationEmail(other, me, meeting);
sendConfirmationEmail(me, other, meeting);
}
return toResponse(meeting, me, other);
}
/** Odwolac moze kazda ze stron, do konca spotkania. Wpis wlasny po prostu znika. */
@Transactional
public void cancel(AppUser me, Long meetingId) {
Meeting meeting = requireParticipant(me, meetingId);
if (meeting.isPrivateNote()) {
meetingRepository.delete(meeting);
return;
}
if (meeting.getStatus() == Meeting.Status.CANCELLED) {
return;
}
meeting.setStatus(Meeting.Status.CANCELLED);
meeting.setCancelledById(me.getId());
meeting.setUpdatedAt(Instant.now());
AppUser other = requireUser(meeting.otherParticipant(me.getId()));
String when = when(meeting);
Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
if (conversation != null) {
conversationService.appendMeetingNote(conversation, me, "Odwołanie oglądania: " + when);
}
notify(other, "Oglądanie odwołane", displayName(me) + " odwołuje termin: " + when, meeting.getConversationId());
}
private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) {
if (!wantsNotifications(recipient)) {
return;
}
accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other),
meeting.getListingTitle(), when(meeting), meeting.getLocation(), meeting.getConversationId());
}
private void notify(AppUser recipient, String title, String body, Long conversationId) {
// Terminy oglądania ustala się w rozmowie, więc podlegają temu samemu przełącznikowi
// co wiadomości. Sam termin zostaje w kalendarzu na /konto/spotkania.
if (!wantsNotifications(recipient)) {
return;
}
notificationService.create(recipient.getEmail(), "messages", "calendar", title, body,
conversationId == null ? "meetings" : "messages:conversation:" + conversationId, null);
}
private boolean wantsNotifications(AppUser recipient) {
return userSettingsRepository.findByUserId(recipient.getId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
}
private String when(Meeting meeting) {
return meeting.getMeetingDate().format(DATE) + ", " + meeting.getStartTime().format(TIME)
+ "-" + meeting.getEndTime().format(TIME);
}
private void validate(MeetingRequest request) {
if (request == null || request.title() == null || request.title().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj nazwę spotkania");
}
if (request.date() == null || request.start() == null || request.end() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj datę i godziny spotkania");
}
if (!request.end().isAfter(request.start())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Godzina zakończenia musi być późniejsza niż rozpoczęcia");
}
if (request.date().isBefore(LocalDate.now())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości");
}
// Limity kolumn w bazie - dluzszy tekst konczyl sie bledem 500 zamiast czytelnym komunikatem.
if (request.title().trim().length() > 160
|| (request.note() != null && request.note().trim().length() > 200)
|| (request.location() != null && request.location().trim().length() > 200)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Za długi tekst - nazwa do 160 znaków, notatka i miejsce do 200 znaków");
}
}
private Conversation requireConversation(AppUser me, Long conversationId) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
if (!conversation.isParticipant(me.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
}
return conversation;
}
private Meeting requireParticipant(AppUser me, Long meetingId) {
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono spotkania"));
if (!meeting.isParticipant(me.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tego spotkania");
}
return meeting;
}
private AppUser requireUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto drugiej strony zostało usunięte"));
}
private MeetingResponse toResponse(Meeting meeting, AppUser me, AppUser other) {
return new MeetingResponse(
meeting.getId(),
meeting.getConversationId(),
meeting.getListingId(),
meeting.getListingTitle(),
meeting.getTitle(),
meeting.getNote(),
meeting.getLocation(),
meeting.getMeetingDate(),
meeting.getStartTime(),
meeting.getEndTime(),
meeting.getStatus().name(),
meeting.getProposerId().equals(me.getId()),
meeting.isPrivateNote(),
meeting.isPrivateNote() ? null : displayName(other),
meeting.getCancelledById() != null && meeting.getCancelledById().equals(me.getId()));
}
private static String displayName(AppUser user) {
if (user == null) {
return "Konto usunięte";
}
String name = user.getFullName();
if (name != null && !name.isBlank()) {
return name;
}
// Bez imienia - nick, jak w wiadomosciach. Adres e-mail (login) nie trafia do drugiej strony.
return user.getNick() != null ? user.getNick() : "Użytkownik";
}
private static String trimOrNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,137 @@
package pl.polskalokalnie.message;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
/**
* Rozmowa o konkretnym ogloszeniu: jeden watek na pare (ogloszenie, kupujacy). Sprzedajacy to
* wlasciciel ogloszenia w chwili zalozenia rozmowy. Wiadomosci leza w tabeli messages
* z ustawionym conversationId.
*
* Tytul ogloszenia jest zapisany w rozmowie, bo ogloszenie moze zostac usuniete - historia
* rozmowy ma wtedy nadal czytelny temat.
*/
@Entity
@Table(name = "conversations", uniqueConstraints = @UniqueConstraint(columnNames = {"listingId", "buyerId"}))
public class Conversation {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long listingId;
@Column(nullable = false, length = 300)
private String listingTitle;
@Column(nullable = false)
private Long buyerId;
@Column(nullable = false)
private Long sellerId;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant lastMessageAt;
// Ukrycie rozmowy dziala osobno dla kazdej strony; nowa wiadomosc przywraca ja obu.
@Column(nullable = false)
private boolean buyerDeleted = false;
@Column(nullable = false)
private boolean sellerDeleted = false;
@PrePersist
void onCreate() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (lastMessageAt == null) {
lastMessageAt = now;
}
}
public boolean isParticipant(Long userId) {
return buyerId.equals(userId) || sellerId.equals(userId);
}
public Long otherParticipant(Long userId) {
return buyerId.equals(userId) ? sellerId : buyerId;
}
public boolean isDeletedFor(Long userId) {
return buyerId.equals(userId) ? buyerDeleted : sellerDeleted;
}
public void markDeletedFor(Long userId) {
if (buyerId.equals(userId)) {
buyerDeleted = true;
} else {
sellerDeleted = true;
}
}
public void restoreForBoth() {
buyerDeleted = false;
sellerDeleted = false;
}
public Long getId() {
return id;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getListingTitle() {
return listingTitle;
}
public void setListingTitle(String listingTitle) {
this.listingTitle = listingTitle;
}
public Long getBuyerId() {
return buyerId;
}
public void setBuyerId(Long buyerId) {
this.buyerId = buyerId;
}
public Long getSellerId() {
return sellerId;
}
public void setSellerId(Long sellerId) {
this.sellerId = sellerId;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getLastMessageAt() {
return lastMessageAt;
}
public void setLastMessageAt(Instant lastMessageAt) {
this.lastMessageAt = lastMessageAt;
}
}
@@ -0,0 +1,78 @@
package pl.polskalokalnie.message;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
import pl.polskalokalnie.message.ConversationDtos.StartConversationRequest;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/** Rozmowy miedzy uzytkownikami o ogloszeniach. Wszystkie trasy wymagaja zalogowania. */
@RestController
@RequestMapping("/api/conversations")
public class ConversationController {
private final ConversationService conversationService;
private final UserRepository userRepository;
public ConversationController(ConversationService conversationService, UserRepository userRepository) {
this.conversationService = conversationService;
this.userRepository = userRepository;
}
@PostMapping
public ConversationDetail start(Authentication authentication, @Valid @RequestBody StartConversationRequest request) {
return conversationService.start(currentUser(authentication), request.listingId(), request.content());
}
@GetMapping
public List<ConversationSummary> list(Authentication authentication) {
return conversationService.list(currentUser(authentication));
}
@GetMapping("/{id}")
public ConversationDetail get(Authentication authentication, @PathVariable Long id) {
return conversationService.get(currentUser(authentication), id);
}
@PostMapping("/{id}/messages")
public ConversationDetail reply(Authentication authentication, @PathVariable Long id,
@Valid @RequestBody SendMessageRequest request) {
return conversationService.reply(currentUser(authentication), id, request.content());
}
@PostMapping("/{id}/block")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void block(Authentication authentication, @PathVariable Long id) {
conversationService.block(currentUser(authentication), id);
}
@DeleteMapping("/{id}/block")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unblock(Authentication authentication, @PathVariable Long id) {
conversationService.unblock(currentUser(authentication), id);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(Authentication authentication, @PathVariable Long id) {
conversationService.deleteForUser(currentUser(authentication), id);
}
private AppUser currentUser(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
}
}
@@ -0,0 +1,74 @@
package pl.polskalokalnie.message;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** Obiekty wymiany danych dla rozmow o ogloszeniach (/api/conversations). */
public final class ConversationDtos {
private ConversationDtos() {
}
public record StartConversationRequest(
@NotNull Long listingId,
@NotBlank @Size(max = 2000) String content
) {
}
/** Skrot ogloszenia w naglowku rozmowy; pola ogloszenia sa null, gdy zostalo usuniete. */
public record ConversationListing(
Long listingId,
String title,
boolean available,
BigDecimal price,
String city,
String district,
Double area,
Integer rooms,
String floor,
String buildingFloors,
String thumbnail
) {
}
public record ConversationSummary(
Long id,
ConversationListing listing,
Long otherUserId,
String otherUserName,
boolean otherUserExists,
boolean selling,
String lastMessage,
boolean lastMessageMine,
long unreadCount,
Instant lastMessageAt
) {
}
/** read ma znaczenie tylko dla wlasnych wiadomosci - czy druga strona je przeczytala. */
public record ConversationMessage(
Long id,
String content,
boolean mine,
boolean read,
Instant createdAt
) {
}
public record ConversationDetail(
Long id,
ConversationListing listing,
Long otherUserId,
String otherUserName,
boolean otherUserExists,
boolean selling,
boolean blockedByMe,
boolean blocked,
List<ConversationMessage> messages
) {
}
}
@@ -0,0 +1,72 @@
package pl.polskalokalnie.message;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import pl.polskalokalnie.auth.AccountEmailService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Powiadamia odbiorce o nowej wiadomosci w rozmowie o ogloszeniu - dopiero po zatwierdzeniu
* transakcji, zeby nie powiadamiac o wiadomosci, ktora ostatecznie sie nie zapisala. Dziala w tle:
* wolny serwer SMTP nie opoznia wysylki wiadomosci, a blad wysylki nie cofa jej zapisu.
*/
@Component
public class ConversationMessageNotifier {
private static final Logger log = LoggerFactory.getLogger(ConversationMessageNotifier.class);
private final NotificationService notificationService;
private final AccountEmailService accountEmailService;
private final UserSettingsRepository userSettingsRepository;
public ConversationMessageNotifier(NotificationService notificationService,
AccountEmailService accountEmailService,
UserSettingsRepository userSettingsRepository) {
this.notificationService = notificationService;
this.accountEmailService = accountEmailService;
this.userSettingsRepository = userSettingsRepository;
}
public record MessageSentEvent(
Long conversationId,
Long recipientId,
String recipientEmail,
String recipientName,
String senderName,
String listingTitle,
String content
) {
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onMessageSent(MessageSentEvent event) {
try {
// Przelacznik "Wiadomości od użytkowników" w ustawieniach konta wycisza oba kanaly:
// powiadomienie w serwisie i e-mail. Sama wiadomosc i tak czeka w /wiadomosci.
boolean wantsNotification = userSettingsRepository.findByUserId(event.recipientId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (!wantsNotification) {
return;
}
String preview = event.content().length() > 90 ? event.content().substring(0, 90) + "…" : event.content();
notificationService.create(event.recipientEmail(), "messages", "message",
"Nowa wiadomość: " + event.listingTitle(),
event.senderName() + ": " + preview,
"messages:conversation:" + event.conversationId(), null);
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
event.listingTitle(), event.content(), event.conversationId());
} catch (RuntimeException ex) {
log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage());
}
}
}
@@ -0,0 +1,17 @@
package pl.polskalokalnie.message;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ConversationRepository extends JpaRepository<Conversation, Long> {
Optional<Conversation> findByListingIdAndBuyerId(Long listingId, Long buyerId);
// Rozmowy widoczne dla uzytkownika - bez tych, ktore ukryl u siebie.
@Query("select c from Conversation c where (c.buyerId = :userId and c.buyerDeleted = false) "
+ "or (c.sellerId = :userId and c.sellerDeleted = false) order by c.lastMessageAt desc")
List<Conversation> findVisibleFor(@Param("userId") Long userId);
}
@@ -0,0 +1,298 @@
package pl.polskalokalnie.message;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.message.ConversationDtos.ConversationDetail;
import pl.polskalokalnie.message.ConversationDtos.ConversationListing;
import pl.polskalokalnie.message.ConversationDtos.ConversationMessage;
import pl.polskalokalnie.message.ConversationDtos.ConversationSummary;
import pl.polskalokalnie.message.ConversationMessageNotifier.MessageSentEvent;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/**
* Rozmowy miedzy uzytkownikami o ogloszeniach. Watek zaklada kupujacy pierwsza wiadomoscia
* z ogloszenia; sprzedajacy odpowiada w istniejacym watku. Dostep maja wylacznie obie strony.
*/
@Service
public class ConversationService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final UserBlockRepository userBlockRepository;
private final UserRepository userRepository;
private final ListingRepository listingRepository;
private final TextModerationService textModerationService;
private final ApplicationEventPublisher eventPublisher;
public ConversationService(ConversationRepository conversationRepository,
MessageRepository messageRepository,
UserBlockRepository userBlockRepository,
UserRepository userRepository,
ListingRepository listingRepository,
TextModerationService textModerationService,
ApplicationEventPublisher eventPublisher) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.userBlockRepository = userBlockRepository;
this.userRepository = userRepository;
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.eventPublisher = eventPublisher;
}
@Transactional
public ConversationDetail start(AppUser me, Long listingId, String content) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
if (listing.getStatus() != ListingStatus.APPROVED
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
}
AppUser seller = listing.getOwnerEmail() == null ? null
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
if (seller == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Z autorem tego ogłoszenia nie można się skontaktować");
}
if (seller.getId().equals(me.getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie możesz napisać wiadomości do samego siebie");
}
requireNotBlocked(me.getId(), seller.getId());
Conversation conversation = conversationRepository.findByListingIdAndBuyerId(listingId, me.getId())
.orElseGet(() -> {
Conversation created = new Conversation();
created.setListingId(listingId);
created.setListingTitle(listing.getTitle());
created.setBuyerId(me.getId());
created.setSellerId(seller.getId());
return conversationRepository.save(created);
});
append(conversation, me, seller, content);
return detail(conversation, me.getId());
}
@Transactional(readOnly = true)
public List<ConversationSummary> list(AppUser me) {
List<Conversation> conversations = conversationRepository.findVisibleFor(me.getId());
if (conversations.isEmpty()) {
return List.of();
}
Set<Long> ids = conversations.stream().map(Conversation::getId).collect(Collectors.toSet());
Map<Long, Message> latest = messageRepository.findLatestPerConversation(ids).stream()
.collect(Collectors.toMap(Message::getConversationId, Function.identity()));
Map<Long, Long> unread = new HashMap<>();
for (Object[] row : messageRepository.countUnreadPerConversation(ids, me.getId())) {
unread.put((Long) row[0], (Long) row[1]);
}
Map<Long, AppUser> users = userRepository.findAllById(conversations.stream()
.map(conversation -> conversation.otherParticipant(me.getId())).collect(Collectors.toSet()))
.stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
Map<Long, PropertyListing> listings = listingRepository.findAllById(conversations.stream()
.map(Conversation::getListingId).collect(Collectors.toSet()))
.stream().collect(Collectors.toMap(PropertyListing::getId, Function.identity()));
return conversations.stream().map(conversation -> {
AppUser other = users.get(conversation.otherParticipant(me.getId()));
Message last = latest.get(conversation.getId());
return new ConversationSummary(
conversation.getId(),
listingOf(conversation, listings.get(conversation.getListingId())),
conversation.otherParticipant(me.getId()),
displayName(other),
other != null,
conversation.getSellerId().equals(me.getId()),
last == null ? "" : last.getContent(),
last != null && last.getSenderId().equals(me.getId()),
unread.getOrDefault(conversation.getId(), 0L),
conversation.getLastMessageAt());
}).toList();
}
/** Otwarcie rozmowy oznacza wiadomosci od drugiej strony jako przeczytane. */
@Transactional
public ConversationDetail get(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
if (conversation.isDeletedFor(me.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy");
}
messageRepository.markConversationMessagesRead(conversation.getId(), me.getId());
return detail(conversation, me.getId());
}
@Transactional
public ConversationDetail reply(AppUser me, Long conversationId, String content) {
Conversation conversation = requireParticipant(me, conversationId);
AppUser other = userRepository.findById(conversation.otherParticipant(me.getId()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto rozmówcy zostało usunięte"));
requireNotBlocked(me.getId(), other.getId());
append(conversation, me, other, content);
return detail(conversation, me.getId());
}
@Transactional
public void block(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
Long otherId = conversation.otherParticipant(me.getId());
if (!userBlockRepository.existsByBlockerIdAndBlockedId(me.getId(), otherId)) {
userBlockRepository.save(new UserBlock(me.getId(), otherId));
}
}
@Transactional
public void unblock(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
userBlockRepository.deleteByBlockerIdAndBlockedId(me.getId(), conversation.otherParticipant(me.getId()));
}
/** Ukrywa rozmowe tylko u wywolujacego; nowa wiadomosc przywroci ja obu stronom. */
@Transactional
public void deleteForUser(AppUser me, Long conversationId) {
Conversation conversation = requireParticipant(me, conversationId);
conversation.markDeletedFor(me.getId());
}
/** Licznik nieprzeczytanych wiadomosci we wszystkich rozmowach o ogloszeniach. */
public long unreadCount(AppUser me) {
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
}
/**
* Wpis o spotkaniu w watku rozmowy. Swiadomie NIE publikuje zdarzenia o nowej wiadomosci:
* o terminach powiadamia modul spotkan (dzwonek zawsze, e-mail tylko przy potwierdzeniu),
* wiec kazda propozycja nie ma prawa wygenerowac osobnego maila.
*/
@Transactional
public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
// Notatka niesie tekst od uzytkownika (miejsce spotkania) - podlega tej samej moderacji co wiadomosc.
textModerationService.validateOrThrow(content);
Message message = new Message();
message.setConversationId(conversation.getId());
message.setSenderId(sender.getId());
message.setRecipientId(conversation.otherParticipant(sender.getId()));
message.setContent(content.trim());
messageRepository.save(message);
conversation.setLastMessageAt(Instant.now());
conversation.restoreForBoth();
}
private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) {
textModerationService.validateOrThrow(content);
String trimmed = content.trim();
Message message = new Message();
message.setConversationId(conversation.getId());
message.setSenderId(sender.getId());
message.setRecipientId(recipient.getId());
message.setContent(trimmed);
messageRepository.save(message);
conversation.setLastMessageAt(Instant.now());
conversation.restoreForBoth();
eventPublisher.publishEvent(new MessageSentEvent(
conversation.getId(),
recipient.getId(),
recipient.getEmail(),
recipient.getFullName(),
displayName(sender),
conversation.getListingTitle(),
trimmed));
}
private ConversationDetail detail(Conversation conversation, Long meId) {
Long otherId = conversation.otherParticipant(meId);
AppUser other = userRepository.findById(otherId).orElse(null);
PropertyListing listing = listingRepository.findById(conversation.getListingId()).orElse(null);
boolean blockedByMe = userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId);
boolean blocked = blockedByMe || userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId);
List<ConversationMessage> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId())
.stream()
.map(message -> new ConversationMessage(
message.getId(),
message.getContent(),
message.getSenderId().equals(meId),
message.isRead(),
message.getCreatedAt()))
.toList();
return new ConversationDetail(
conversation.getId(),
listingOf(conversation, listing),
otherId,
displayName(other),
other != null,
conversation.getSellerId().equals(meId),
blockedByMe,
blocked,
messages);
}
private ConversationListing listingOf(Conversation conversation, PropertyListing listing) {
if (listing == null) {
return new ConversationListing(conversation.getListingId(), conversation.getListingTitle(), false,
null, null, null, null, null, null, null, null);
}
return new ConversationListing(
listing.getId(),
listing.getTitle(),
listing.getStatus() == ListingStatus.APPROVED,
listing.getPrice(),
listing.getCity(),
listing.getDistrict(),
listing.getArea(),
listing.getRooms(),
listing.getFloor(),
listing.getBuildingFloors(),
listing.getCoverPhoto());
}
private Conversation requireParticipant(AppUser me, Long conversationId) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
if (!conversation.isParticipant(me.getId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
}
return conversation;
}
/** Blokada miedzy uczestnikami rozmowy obowiazuje tez poza wiadomosciami (np. propozycje spotkan). */
public void requireCanWrite(Conversation conversation, AppUser me) {
requireNotBlocked(me.getId(), conversation.otherParticipant(me.getId()));
}
private void requireNotBlocked(Long meId, Long otherId) {
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
}
if (userBlockRepository.existsByBlockerIdAndBlockedId(otherId, meId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie możesz pisać do tego użytkownika.");
}
}
private static String displayName(AppUser user) {
if (user == null) {
return "Konto usunięte";
}
if (user.getFullName() != null && !user.getFullName().isBlank()) {
return user.getFullName().trim();
}
return user.getNick() != null ? user.getNick() : "Użytkownik";
}
}
@@ -23,6 +23,9 @@ public class Message {
@Column(nullable = false) @Column(nullable = false)
private Long recipientId; private Long recipientId;
// Rozmowa o ogloszeniu miedzy uzytkownikami; null = rozmowa z administracja serwisu.
private Long conversationId;
@Column(nullable = false, length = 2000) @Column(nullable = false, length = 2000)
private String content; private String content;
@@ -59,6 +62,14 @@ public class Message {
this.recipientId = recipientId; this.recipientId = recipientId;
} }
public Long getConversationId() {
return conversationId;
}
public void setConversationId(Long conversationId) {
this.conversationId = conversationId;
}
public String getContent() { public String getContent() {
return content; return content;
} }
@@ -18,10 +18,13 @@ import pl.polskalokalnie.user.UserRepository;
public class MessageController { public class MessageController {
private final MessageService messageService; private final MessageService messageService;
private final ConversationService conversationService;
private final UserRepository userRepository; private final UserRepository userRepository;
public MessageController(MessageService messageService, UserRepository userRepository) { public MessageController(MessageService messageService, ConversationService conversationService,
UserRepository userRepository) {
this.messageService = messageService; this.messageService = messageService;
this.conversationService = conversationService;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
@@ -39,11 +42,13 @@ public class MessageController {
return messageService.send(me.getId(), admin.getId(), request.content()); return messageService.send(me.getId(), admin.getId(), request.content());
} }
// Wszystkie nieprzeczytane: od administracji oraz z rozmow o ogloszeniach.
@GetMapping("/unread-count") @GetMapping("/unread-count")
public UnreadCountResponse unreadCount(Authentication authentication) { public UnreadCountResponse unreadCount(Authentication authentication) {
AppUser me = currentUser(authentication); AppUser me = currentUser(authentication);
AppUser admin = messageService.resolveAdmin(); AppUser admin = messageService.resolveAdmin();
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId())); return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId())
+ conversationService.unreadCount(me));
} }
private AppUser currentUser(Authentication authentication) { private AppUser currentUser(Authentication authentication) {
@@ -1,5 +1,6 @@
package pl.polskalokalnie.message; package pl.polskalokalnie.message;
import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
@@ -9,14 +10,38 @@ import org.springframework.transaction.annotation.Transactional;
public interface MessageRepository extends JpaRepository<Message, Long> { public interface MessageRepository extends JpaRepository<Message, Long> {
@Query("select m from Message m where (m.senderId = :first and m.recipientId = :second) " // --- Rozmowa z administracja (wiadomosci bez conversationId) ---
+ "or (m.senderId = :second and m.recipientId = :first) order by m.createdAt asc")
@Query("select m from Message m where m.conversationId is null and ((m.senderId = :first and m.recipientId = :second) "
+ "or (m.senderId = :second and m.recipientId = :first)) order by m.createdAt asc")
List<Message> findConversation(@Param("first") Long first, @Param("second") Long second); List<Message> findConversation(@Param("first") Long first, @Param("second") Long second);
long countByRecipientIdAndSenderIdAndReadFalse(Long recipientId, Long senderId); long countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(Long recipientId, Long senderId);
@Modifying @Modifying
@Transactional @Transactional
@Query("update Message m set m.read = true where m.recipientId = :recipientId and m.senderId = :senderId and m.read = false") @Query("update Message m set m.read = true where m.conversationId is null and m.recipientId = :recipientId "
+ "and m.senderId = :senderId and m.read = false")
void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId); void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId);
// --- Rozmowy o ogloszeniach miedzy uzytkownikami ---
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
// Ostatnia wiadomosc kazdej rozmowy jednym zapytaniem - bez N+1 na liscie rozmow.
@Query("select m from Message m where m.id in "
+ "(select max(m2.id) from Message m2 where m2.conversationId in :ids group by m2.conversationId)")
List<Message> findLatestPerConversation(@Param("ids") Collection<Long> ids);
@Query("select m.conversationId, count(m) from Message m where m.conversationId in :ids "
+ "and m.recipientId = :userId and m.read = false group by m.conversationId")
List<Object[]> countUnreadPerConversation(@Param("ids") Collection<Long> ids, @Param("userId") Long userId);
long countByRecipientIdAndReadFalseAndConversationIdIsNotNull(Long recipientId);
@Modifying
@Transactional
@Query("update Message m set m.read = true where m.conversationId = :conversationId "
+ "and m.recipientId = :recipientId and m.read = false")
void markConversationMessagesRead(@Param("conversationId") Long conversationId, @Param("recipientId") Long recipientId);
} }
@@ -83,6 +83,6 @@ public class MessageService {
} }
public long countUnreadFrom(Long recipientId, Long senderId) { public long countUnreadFrom(Long recipientId, Long senderId) {
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId); return messageRepository.countByRecipientIdAndSenderIdAndReadFalseAndConversationIdIsNull(recipientId, senderId);
} }
} }
@@ -0,0 +1,57 @@
package pl.polskalokalnie.message;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
/** Blokada miedzy uzytkownikami - wstrzymuje wiadomosci w obu kierunkach. */
@Entity
@Table(name = "user_blocks", uniqueConstraints = @UniqueConstraint(columnNames = {"blockerId", "blockedId"}))
public class UserBlock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long blockerId;
@Column(nullable = false)
private Long blockedId;
@Column(nullable = false, updatable = false)
private Instant createdAt;
protected UserBlock() {
}
public UserBlock(Long blockerId, Long blockedId) {
this.blockerId = blockerId;
this.blockedId = blockedId;
}
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public Long getBlockerId() {
return blockerId;
}
public Long getBlockedId() {
return blockedId;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
public interface UserBlockRepository extends JpaRepository<UserBlock, Long> {
boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
@Transactional
void deleteByBlockerIdAndBlockedId(Long blockerId, Long blockedId);
}
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pl.polskalokalnie.auth.JwtService;
@RestController @RestController
@RequestMapping("/api/notifications") @RequestMapping("/api/notifications")
public class NotificationController { public class NotificationController {
private final NotificationService notificationService; private final NotificationService notificationService;
private final JwtService jwtService;
public NotificationController(NotificationService notificationService) { public NotificationController(NotificationService notificationService, JwtService jwtService) {
this.notificationService = notificationService; this.notificationService = notificationService;
this.jwtService = jwtService;
} }
@GetMapping @GetMapping
@@ -86,6 +89,15 @@ public class NotificationController {
return created == null ? null : NotificationResponse.from(created); return created == null ? null : NotificationResponse.from(created);
} }
/** Krotki token do otwarcia strumienia (EventSource przekazuje go w adresie, nie w naglowku). */
@PostMapping("/stream-token")
public StreamTokenResponse streamToken(Authentication authentication) {
return new StreamTokenResponse(jwtService.generateStreamToken(authentication.getName()));
}
public record StreamTokenResponse(String token) {
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) { public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName()); return notificationService.subscribe(authentication.getName());
@@ -0,0 +1,242 @@
package pl.polskalokalnie.og;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
/**
* Serwerowe renderowanie strony ogloszenia dla crawlerow social media (Facebook, WhatsApp,
* Messenger, Twitter/X...). Te boty nie wykonuja JavaScriptu, wiec meta tagi ustawiane dynamicznie
* w Reakcie sa dla nich niewidoczne - podglad linku bylby pusty. Nginx wykrywa crawlera po
* User-Agent i dla adresu /oferta/{id} kieruje go tutaj zamiast do aplikacji SPA; zwykly uzytkownik
* dostaje niezmieniona aplikacje. Zwracany HTML zawiera Open Graph/Twitter Card z tytulem, cena,
* metrazem, lokalizacja i glownym zdjeciem, a nastepnie przekierowuje do kanonicznego adresu SPA.
*/
@RestController
@RequestMapping("/api/og")
public class OgController {
private final ListingRepository listingRepository;
private final String baseUrl;
public OgController(ListingRepository listingRepository,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.listingRepository = listingRepository;
// Bez ukosnika na koncu - laczymy przez konkatenacje ze sciezka zaczynajaca sie od "/".
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
@Transactional(readOnly = true)
@GetMapping(value = "/listing/{id}", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")
public ResponseEntity<String> listingPreview(@PathVariable Long id) {
String canonicalUrl = baseUrl + "/oferta/" + id;
PropertyListing listing = listingRepository.findById(id).orElse(null);
// Nieopublikowane ogloszenia (oczekujace, wstrzymane, odrzucone) i nieistniejace nie wyciekaja
// danych - crawler dostaje ogolna wizytowke serwisu z przekierowaniem do aplikacji.
if (listing == null || listing.getStatus() != ListingStatus.APPROVED) {
String html = renderHtml(
"Polska Lokalnie",
"Ogloszenia sprzedazy i wynajmu mieszkan, domow oraz apartamentow.",
null,
null,
canonicalUrl);
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache())
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
.body(html);
}
String location = formatLocation(listing);
// Opis podgladu = cena · metraz · lokalizacja - dokladnie to, co ma zobaczyc odbiorca linku.
String description = String.join(" · ", nonBlank(
formatPrice(listing),
formatArea(listing.getArea()),
location));
String imageUrl = hasImage(listing) ? baseUrl + "/api/og/listing/" + id + "/image" : null;
String html = renderHtml(listing.getTitle(), description, imageUrl, listing.getTitle(), canonicalUrl);
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache())
.contentType(MediaType.valueOf("text/html;charset=UTF-8"))
.body(html);
}
/**
* Glowne zdjecie ogloszenia jako realny obraz pod stabilnym adresem. Zdjecia trzymamy jako
* data URL (base64) - crawler nie potrafi ich uzyc jako og:image, wiec dekodujemy je tutaj do
* bajtow z prawidlowym Content-Type.
*/
@Transactional(readOnly = true)
@GetMapping("/listing/{id}/image")
public ResponseEntity<byte[]> listingImage(@PathVariable Long id) {
PropertyListing listing = listingRepository.findById(id).orElse(null);
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
return ResponseEntity.notFound().build();
}
DecodedImage image = decodeDataUrl(listing.getPhotos().get(0));
if (image == null) {
// Bez przekierowania pod zapisany adres - to byloby otwarte przekierowanie na dowolna strone.
return ResponseEntity.notFound().build();
}
// nosniff + sandbox: nawet gdyby w bazie siedzial spreparowany plik, przegladarka nie wykona
// go jako strony w domenie serwisu.
return ResponseEntity.ok()
.contentType(MediaType.valueOf(image.mimeType()))
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
.header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "default-src 'none'; sandbox")
.body(image.data());
}
private static boolean hasImage(PropertyListing listing) {
List<String> photos = listing.getPhotos();
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
}
record DecodedImage(String mimeType, byte[] data) {}
// Tylko obrazy rastrowe - typ MIME pochodzi od uzytkownika i nie moze decydowac o tym,
// jako co przegladarka potraktuje odpowiedz (text/html, image/svg+xml = skrypt w domenie serwisu).
private static final java.util.Set<String> ALLOWED_IMAGE_TYPES = java.util.Set.of(
MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, "image/webp");
static DecodedImage decodeDataUrl(String value) {
if (value == null || !value.startsWith("data:")) {
return null;
}
int comma = value.indexOf(',');
if (comma < 0) {
return null;
}
String meta = value.substring(5, comma); // np. "image/jpeg;base64"
if (!meta.contains(";base64")) {
return null;
}
String mime = meta.substring(0, meta.indexOf(';'));
if (mime.isBlank()) {
mime = MediaType.IMAGE_JPEG_VALUE;
}
mime = mime.trim().toLowerCase(Locale.ROOT);
if (!ALLOWED_IMAGE_TYPES.contains(mime)) {
return null;
}
try {
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
return new DecodedImage(mime, data);
} catch (IllegalArgumentException ex) {
return null;
}
}
// --- Formatowanie zgodne z prezentacja na froncie (formatPln, "X m²", "district, city") ---
private static String formatPrice(PropertyListing listing) {
BigDecimal price = listing.getPrice();
if (price == null) {
return "";
}
NumberFormat format = NumberFormat.getIntegerInstance(new Locale("pl", "PL"));
String amount = format.format(price.setScale(0, RoundingMode.HALF_UP)).replace(' ', ' ');
String base = amount + " zł";
return listing.getOfferType() == OfferType.RENT ? base + " / mies." : base;
}
private static String formatArea(Double area) {
if (area == null || area <= 0) {
return "";
}
if (area == Math.floor(area)) {
return (long) area.doubleValue() + " m²";
}
return String.format(new Locale("pl", "PL"), "%.1f m²", area);
}
private static String formatLocation(PropertyListing listing) {
String city = listing.getCity() == null ? "" : listing.getCity().trim();
String district = listing.getDistrict() == null ? "" : listing.getDistrict().trim();
if (!district.isEmpty() && !city.isEmpty()) {
return district + ", " + city;
}
return city.isEmpty() ? district : city;
}
private static String[] nonBlank(String... values) {
return java.util.Arrays.stream(values).filter(v -> v != null && !v.isBlank()).toArray(String[]::new);
}
private String renderHtml(String title, String description, String imageUrl, String imageAlt, String canonicalUrl) {
String t = escapeHtml(title);
String d = escapeHtml(description);
StringBuilder head = new StringBuilder();
head.append("<meta property=\"og:type\" content=\"website\"/>\n");
head.append("<meta property=\"og:site_name\" content=\"Polska Lokalnie\"/>\n");
head.append("<meta property=\"og:locale\" content=\"pl_PL\"/>\n");
head.append("<meta property=\"og:title\" content=\"").append(t).append("\"/>\n");
head.append("<meta property=\"og:description\" content=\"").append(d).append("\"/>\n");
head.append("<meta property=\"og:url\" content=\"").append(escapeHtml(canonicalUrl)).append("\"/>\n");
head.append("<meta name=\"twitter:card\" content=\"").append(imageUrl != null ? "summary_large_image" : "summary").append("\"/>\n");
head.append("<meta name=\"twitter:title\" content=\"").append(t).append("\"/>\n");
head.append("<meta name=\"twitter:description\" content=\"").append(d).append("\"/>\n");
if (imageUrl != null) {
String img = escapeHtml(imageUrl);
head.append("<meta property=\"og:image\" content=\"").append(img).append("\"/>\n");
head.append("<meta property=\"og:image:alt\" content=\"").append(escapeHtml(imageAlt)).append("\"/>\n");
head.append("<meta name=\"twitter:image\" content=\"").append(img).append("\"/>\n");
}
return "<!doctype html>\n"
+ "<html lang=\"pl\">\n<head>\n"
+ "<meta charset=\"utf-8\"/>\n"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
+ "<title>" + t + "</title>\n"
+ "<meta name=\"description\" content=\"" + d + "\"/>\n"
+ "<link rel=\"canonical\" href=\"" + escapeHtml(canonicalUrl) + "\"/>\n"
+ head
// Czlowiek, ktory trafi tu z linku (a nie bot), zostaje od razu przeniesiony do aplikacji.
+ "<meta http-equiv=\"refresh\" content=\"0; url=" + escapeHtml(canonicalUrl) + "\"/>\n"
+ "</head>\n<body>\n"
+ "<p><a href=\"" + escapeHtml(canonicalUrl) + "\">" + t + "</a></p>\n"
+ "<script>window.location.replace(" + jsString(canonicalUrl) + ");</script>\n"
+ "</body>\n</html>\n";
}
private static String escapeHtml(String value) {
if (value == null) {
return "";
}
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
// Bezpieczne osadzenie adresu w literale JS (redirect) - adres pochodzi z konfiguracji, ale
// trzymamy sie zasady, ze nic nie trafia do skryptu bez ucieczki.
private static String jsString(String value) {
String escaped = value == null ? "" : value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("<", "\\u003C")
.replace(">", "\\u003E");
return "'" + escaped + "'";
}
}
@@ -0,0 +1,143 @@
package pl.polskalokalnie.payment;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64).
*
* Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD
* i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo
* wymuszac zadania wychodzace (SSRF).
*/
public final class AutoPayItnParser {
private AutoPayItnParser() {
}
/** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */
public record ItnTransaction(
String orderId,
String remoteId,
String amount,
String currency,
String gatewayId,
String paymentDate,
String paymentStatus,
String paymentStatusDetails
) {
public boolean success() {
return "SUCCESS".equalsIgnoreCase(paymentStatus);
}
/** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */
List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(orderId);
values.add(remoteId);
values.add(amount);
values.add(currency);
values.add(gatewayId);
values.add(paymentDate);
values.add(paymentStatus);
values.add(paymentStatusDetails);
return values;
}
}
/** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */
public record Itn(String serviceId, List<ItnTransaction> transactions, String hash) {
/** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */
public List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(serviceId);
for (ItnTransaction tx : transactions) {
values.addAll(tx.signedValues());
}
return values;
}
}
public static Itn parse(String xml) {
Document doc = parseSecurely(xml);
Element root = doc.getDocumentElement();
String serviceId = childText(root, "serviceID");
// Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji).
String hash = childText(root, "hash");
List<ItnTransaction> transactions = new ArrayList<>();
for (Element tx : elementsByTag(root, "transaction")) {
transactions.add(new ItnTransaction(
childText(tx, "orderID"),
childText(tx, "remoteID"),
childText(tx, "amount"),
childText(tx, "currency"),
childText(tx, "gatewayID"),
childText(tx, "paymentDate"),
childText(tx, "paymentStatus"),
childText(tx, "paymentStatusDetails")));
}
if (serviceId == null || transactions.isEmpty()) {
throw invalid();
}
return new Itn(serviceId, transactions, hash);
}
private static Document parseSecurely(String xml) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory.newDocumentBuilder()
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (ParserConfigurationException e) {
throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e);
} catch (Exception e) {
throw invalid();
}
}
private static ResponseStatusException invalid() {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
/** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */
private static String childText(Element parent, String tag) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) {
String text = node.getTextContent();
return text == null || text.isBlank() ? null : text.trim();
}
}
return null;
}
private static List<Element> elementsByTag(Element root, String tag) {
List<Element> result = new ArrayList<>();
NodeList nodes = root.getElementsByTagName(tag);
for (int i = 0; i < nodes.getLength(); i++) {
result.add((Element) nodes.item(i));
}
return result;
}
}
@@ -2,23 +2,19 @@ package pl.polskalokalnie.payment;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media). * Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego
* produkcyjnie w Gieldzie Komunalnej.
* *
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych * Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN. * parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola
* * opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100,
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja * Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
*/ */
@Service @Service
public class AutoPayService { public class AutoPayService {
@@ -26,66 +22,38 @@ public class AutoPayService {
private static final String PROD_URL = "https://pay.autopay.eu/payment"; private static final String PROD_URL = "https://pay.autopay.eu/payment";
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment"; private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
/** Adres paywalla z podpisanymi parametrami transakcji. */ /**
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount, * Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
String description, String customerEmail, String returnUrl) { * (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); * Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
*/
// Kolejnosc parametrow ma znaczenie dla podpisu. public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
Map<String, String> params = new LinkedHashMap<>(); String description, String customerEmail) {
params.put("ServiceID", cfg.getServiceId()); String amountStr = formatAmount(amount);
params.put("OrderID", orderId); String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
params.put("Amount", amountStr); customerEmail == null ? "" : customerEmail));
params.put("Currency", "PLN"); String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
params.put("Description", description == null ? "" : description); return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail); customerEmail, hash);
if (returnUrl != null && !returnUrl.isBlank()) {
params.put("ReturnURL", returnUrl);
}
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
params.put("Hash", hash);
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> e : params.entrySet()) {
if (query.length() > 0) {
query.append('&');
}
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
}
return base + "?" + query;
} }
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */ /** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) { public static String formatAmount(BigDecimal amount) {
if (params == null || cfg == null || cfg.getSecretKey() == null) { return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
return false;
}
String provided = params.get("Hash");
if (provided == null || provided.isBlank()) {
return false;
}
List<String> values = new ArrayList<>();
for (Map.Entry<String, String> e : params.entrySet()) {
if (!"Hash".equalsIgnoreCase(e.getKey())) {
values.add(e.getValue());
}
}
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
} }
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */ /** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
public String computeHash(List<String> values, String separator, String key) { public String hash(PaymentConfig cfg, List<String> values) {
List<String> parts = new ArrayList<>(values); StringBuilder sb = new StringBuilder();
parts.add(key); for (String value : values) {
String data = String.join(separator, parts); if (value == null || value.isEmpty()) {
continue; // puste pole opcjonalne: pomijamy bez separatora
}
sb.append(value).append(cfg.getHashSeparator());
}
sb.append(cfg.getSecretKey());
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2); StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) { for (byte b : bytes) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16)); hex.append(Character.forDigit((b >> 4) & 0xF, 16));
@@ -97,7 +65,16 @@ public class AutoPayService {
} }
} }
private static String enc(String value) { /**
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); * Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
*/
public static boolean hashMatches(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
} }
} }
@@ -0,0 +1,16 @@
package pl.polskalokalnie.payment;
/**
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
*/
public record AutoPayStartForm(
String gatewayUrl,
String serviceId,
String orderId,
String amount,
String description,
String customerEmail,
String hash
) {
}
@@ -0,0 +1,177 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import pl.polskalokalnie.listing.PropertyType;
/**
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
*
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
*/
@Entity
@Table(name = "price_alerts")
public class PriceAlert {
public enum Kind {
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
LISTING,
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
LOCATION
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Kind kind;
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
@Column(nullable = false, length = 200)
private String label;
// --- alert ogloszenia ---
@Column(name = "listing_id")
private Long listingId;
// --- alert lokalizacji ---
@Column(length = 120)
private String city;
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
@Column(length = 60)
private String region;
@Enumerated(EnumType.STRING)
@Column(name = "property_type", length = 20)
private PropertyType propertyType;
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
@Column(length = 20)
private String market;
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
@Column(name = "last_value")
private Long lastValue;
@Column(name = "last_checked_at")
private Instant lastCheckedAt;
@Column(name = "notifications_enabled", nullable = false)
private boolean notificationsEnabled = true;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public Kind getKind() {
return kind;
}
public void setKind(Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public PropertyType getPropertyType() {
return propertyType;
}
public void setPropertyType(PropertyType propertyType) {
this.propertyType = propertyType;
}
public String getMarket() {
return market;
}
public void setMarket(String market) {
this.market = market;
}
public Long getLastValue() {
return lastValue;
}
public void setLastValue(Long lastValue) {
this.lastValue = lastValue;
}
public Instant getLastCheckedAt() {
return lastCheckedAt;
}
public void setLastCheckedAt(Instant lastCheckedAt) {
this.lastCheckedAt = lastCheckedAt;
}
public boolean isNotificationsEnabled() {
return notificationsEnabled;
}
public void setNotificationsEnabled(boolean notificationsEnabled) {
this.notificationsEnabled = notificationsEnabled;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,45 @@
package pl.polskalokalnie.pricealert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
*/
@Component
public class PriceAlertChecker {
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
private final PriceAlertService priceAlertService;
public PriceAlertChecker(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@Scheduled(cron = "0 0 3 * * *")
public void checkDaily() {
run();
}
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
public void checkOnStartup() {
run();
}
private void run() {
try {
int notified = priceAlertService.checkAll();
if (notified > 0) {
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
}
} catch (RuntimeException ex) {
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
}
}
}
@@ -0,0 +1,60 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
@RestController
@RequestMapping("/api/price-alerts")
public class PriceAlertController {
private final PriceAlertService priceAlertService;
public PriceAlertController(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@GetMapping
public List<PriceAlertResponse> list(Authentication authentication) {
return priceAlertService.list(authentication.getName());
}
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
@GetMapping("/events")
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
return priceAlertService.events(authentication.getName(), days);
}
@PostMapping
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
return priceAlertService.create(authentication.getName(), request);
}
public record NotificationsRequest(boolean enabled) {
}
@PutMapping("/{id}/notifications")
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
Authentication authentication) {
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
priceAlertService.delete(authentication.getName(), id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
public final class PriceAlertDtos {
private PriceAlertDtos() {
}
/**
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
*/
public record PriceAlertRequest(Long listingId, String label, String city, String region,
String propertyType, String market) {
}
/**
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
*/
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
boolean notificationsEnabled, Instant createdAt) {
}
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
long previousValue, long currentValue, Instant changedAt) {
}
}
@@ -0,0 +1,113 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
*/
@Entity
@Table(name = "price_alert_events")
public class PriceAlertEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "alert_id", nullable = false)
private Long alertId;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PriceAlert.Kind kind;
@Column(nullable = false, length = 200)
private String label;
@Column(name = "listing_id")
private Long listingId;
@Column(name = "previous_value", nullable = false)
private long previousValue;
@Column(name = "current_value", nullable = false)
private long currentValue;
@Column(name = "changed_at", nullable = false)
private Instant changedAt = Instant.now();
public Long getId() {
return id;
}
public Long getAlertId() {
return alertId;
}
public void setAlertId(Long alertId) {
this.alertId = alertId;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public PriceAlert.Kind getKind() {
return kind;
}
public void setKind(PriceAlert.Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public long getPreviousValue() {
return previousValue;
}
public void setPreviousValue(long previousValue) {
this.previousValue = previousValue;
}
public long getCurrentValue() {
return currentValue;
}
public void setCurrentValue(long currentValue) {
this.currentValue = currentValue;
}
public Instant getChangedAt() {
return changedAt;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
void deleteByAlertId(Long alertId);
}
@@ -0,0 +1,14 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
List<PriceAlert> findByKind(PriceAlert.Kind kind);
}
@@ -0,0 +1,269 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
import pl.polskalokalnie.pricehistory.PriceSnapshotService;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
*
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
* ruchu pocztowego.</p>
*/
@Service
public class PriceAlertService {
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
/**
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
* zmiana jest dla obserwujacego istotna.
*/
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
private final PriceAlertRepository alertRepository;
private final PriceAlertEventRepository eventRepository;
private final ListingRepository listingRepository;
private final PriceSnapshotService snapshotService;
private final NotificationService notificationService;
private final UserRepository userRepository;
private final UserSettingsRepository userSettingsRepository;
public PriceAlertService(PriceAlertRepository alertRepository,
PriceAlertEventRepository eventRepository,
ListingRepository listingRepository,
PriceSnapshotService snapshotService,
NotificationService notificationService,
UserRepository userRepository,
UserSettingsRepository userSettingsRepository) {
this.alertRepository = alertRepository;
this.eventRepository = eventRepository;
this.listingRepository = listingRepository;
this.snapshotService = snapshotService;
this.notificationService = notificationService;
this.userRepository = userRepository;
this.userSettingsRepository = userSettingsRepository;
}
/**
* Globalny przelacznik "Alerty cenowe" z ustawien konta. Wylaczony wycisza wszystkie alerty
* naraz; przelacznik przy pojedynczym alercie dziala niezaleznie i wycisza tylko jego.
* Historia zmian zapisuje sie tak czy inaczej - wyciszamy powiadomienie, nie pomiar.
*/
private boolean wantsAlertNotifications(String userEmail) {
return userRepository.findByEmailIgnoreCase(userEmail)
.flatMap(user -> userSettingsRepository.findByUserId(user.getId()))
.map(UserSettings::isNotifyPriceAlerts)
.orElse(true);
}
@Transactional(readOnly = true)
public List<PriceAlertResponse> list(String userEmail) {
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.map(PriceAlertService::toResponse)
.toList();
}
@Transactional(readOnly = true)
public List<PriceAlertEventResponse> events(String userEmail, int days) {
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
event.getChangedAt()))
.toList();
}
@Transactional
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
if (request == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
}
PriceAlert alert = request.listingId() != null
? buildListingAlert(userEmail, request.listingId())
: buildLocationAlert(userEmail, request);
alert.setLastValue(measure(alert));
alert.setLastCheckedAt(Instant.now());
return toResponse(alertRepository.save(alert));
}
@Transactional
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
PriceAlert alert = requireOwn(userEmail, id);
alert.setNotificationsEnabled(enabled);
return toResponse(alert);
}
@Transactional
public void delete(String userEmail, Long id) {
PriceAlert alert = requireOwn(userEmail, id);
eventRepository.deleteByAlertId(alert.getId());
alertRepository.delete(alert);
}
/**
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
*/
@Transactional
public int checkAll() {
int notified = 0;
for (PriceAlert alert : alertRepository.findAll()) {
try {
if (checkOne(alert)) {
notified++;
}
} catch (RuntimeException ex) {
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
}
}
return notified;
}
private boolean checkOne(PriceAlert alert) {
Long current = measure(alert);
if (current == null) {
return false;
}
Long previous = alert.getLastValue();
alert.setLastValue(current);
alert.setLastCheckedAt(Instant.now());
if (previous == null || previous <= 0 || previous.equals(current)) {
return false;
}
double changePct = ((current - previous) * 100.0) / previous;
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
return false;
}
PriceAlertEvent event = new PriceAlertEvent();
event.setAlertId(alert.getId());
event.setUserEmail(alert.getUserEmail());
event.setKind(alert.getKind());
event.setLabel(alert.getLabel());
event.setListingId(alert.getListingId());
event.setPreviousValue(previous);
event.setCurrentValue(current);
eventRepository.save(event);
if (alert.isNotificationsEnabled() && wantsAlertNotifications(alert.getUserEmail())) {
notify(alert, previous, current, changePct);
}
return true;
}
private void notify(PriceAlert alert, long previous, long current, double changePct) {
boolean down = current < previous;
String direction = down ? "spadła" : "wzrosła";
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
? "listing:" + alert.getListingId()
: "priceAlerts";
String title = alert.getKind() == PriceAlert.Kind.LISTING
? "Cena " + direction + ": " + alert.getLabel()
: "Zmiana cen: " + alert.getLabel();
String body = alert.getKind() == PriceAlert.Kind.LISTING
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
title, body, link, dedupeKey);
}
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
private Long measure(PriceAlert alert) {
if (alert.getKind() == PriceAlert.Kind.LISTING) {
return listingRepository.findById(alert.getListingId())
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(PropertyListing::getPrice)
.map(price -> price.longValue())
.orElse(null);
}
PriceSnapshotService.Average average = snapshotService.currentAverage(
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
alert.getCity(), "");
return average == null ? null : average.avgPricePerM2();
}
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
if (existing != null) {
return existing;
}
PriceAlert alert = new PriceAlert();
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LISTING);
alert.setListingId(listingId);
alert.setLabel(listing.getTitle());
return alert;
}
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
if (request.city() == null || request.city().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
}
String city = request.city().trim();
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
String market = request.market() == null ? "" : request.market().trim();
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
.filter(item -> city.equalsIgnoreCase(item.getCity()))
.filter(item -> item.getPropertyType() == propertyType)
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
.findFirst()
.orElseGet(PriceAlert::new);
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LOCATION);
alert.setCity(city);
alert.setRegion(request.region() == null ? null : request.region().trim());
alert.setPropertyType(propertyType);
alert.setMarket(market);
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
return alert;
}
private PriceAlert requireOwn(String userEmail, Long id) {
return alertRepository.findById(id)
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
}
private static String money(long value) {
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
}
private static PriceAlertResponse toResponse(PriceAlert alert) {
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
alert.getCity(), alert.getRegion(),
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
alert.isNotificationsEnabled(), alert.getCreatedAt());
}
}
@@ -0,0 +1,166 @@
package pl.polskalokalnie.pricehistory;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
/**
* Import cen transakcyjnych z NBP (plik XLSX) i GUS (API BDL) - zrodla porownawcze dla historii cen.
* Raz w tygodniu oraz przy starcie, gdy dane sa starsze niz tydzien. Blad zrodla nie rusza starych danych.
*/
@Service
public class ExternalPriceImportService {
static final String NBP_URL = "https://static.nbp.pl/dane/rynek-nieruchomosci/ceny_mieszkan.xlsx";
private static final long MAX_DOWNLOAD_BYTES = 20L * 1024 * 1024;
private static final int GUS_FROM_YEAR = 2010;
private static final Logger log = LoggerFactory.getLogger(ExternalPriceImportService.class);
private final ExternalQuarterPriceRepository repository;
private final ListingRepository listingRepository;
private final GusBdlClient gusClient;
private final TransactionTemplate transaction;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ExternalPriceImportService(ExternalQuarterPriceRepository repository, ListingRepository listingRepository,
GusBdlClient gusClient, TransactionTemplate transaction) {
this.repository = repository;
this.listingRepository = listingRepository;
this.gusClient = gusClient;
this.transaction = transaction;
}
@Async
@EventListener(ApplicationReadyEvent.class)
public void importOnStartupIfStale() {
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
if (repository.findLastImport(Source.NBP).map(last -> last.isBefore(weekAgo)).orElse(true)) {
importNbp();
}
if (repository.findLastImport(Source.GUS).map(last -> last.isBefore(weekAgo)).orElse(true)) {
importGus();
}
}
@Scheduled(cron = "0 10 4 * * MON", zone = "Europe/Warsaw")
public void importWeekly() {
importNbp();
importGus();
}
public int importNbp() {
try {
List<NbpXlsxParser.Row> rows;
try (InputStream xlsx = download(NBP_URL)) {
rows = NbpXlsxParser.parse(xlsx);
}
if (rows.isEmpty()) {
log.warn("NBP: w pliku nie znaleziono cen transakcyjnych - zostawiam poprzednie dane");
return 0;
}
Instant now = Instant.now();
transaction.executeWithoutResult(status -> {
repository.deleteBySource(Source.NBP);
repository.saveAll(rows.stream()
.map(row -> new ExternalQuarterPrice(Source.NBP, row.year(), row.quarter(), row.market(),
row.city(), row.pricePerM2(), now))
.toList());
});
log.info("NBP: zaimportowano {} cen kwartalnych", rows.size());
return rows.size();
} catch (IOException | RuntimeException ex) {
log.warn("NBP: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
return 0;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return 0;
}
}
/** GUS dla miast z ogloszen i miast z danych NBP - tylko miasta na prawach powiatu maja wlasne dane. */
public int importGus() {
int imported = 0;
try {
List<GusBdlClient.Variable> variables = gusClient.variables();
int toYear = PriceSnapshotService.today().getYear();
for (String city : citiesToTrack()) {
try {
String unitId = gusClient.cityUnitId(city);
if (unitId != null) {
List<GusBdlClient.QuarterValue> values = gusClient.quarterlyPrices(unitId, variables, GUS_FROM_YEAR, toYear);
if (!values.isEmpty()) {
Instant now = Instant.now();
transaction.executeWithoutResult(status -> {
repository.deleteBySourceAndCityKey(Source.GUS, PriceSnapshotService.key(city));
repository.saveAll(values.stream()
.map(value -> new ExternalQuarterPrice(Source.GUS, value.year(), value.quarter(),
value.market(), city, value.pricePerM2(), now))
.toList());
});
imported += values.size();
}
}
// Publiczne API bez klucza ma limity zapytan - nie spieszymy sie.
Thread.sleep(700);
} catch (IOException | RuntimeException ex) {
log.warn("GUS: nie udalo sie pobrac danych dla {} ({})", city, ex.getMessage());
}
}
log.info("GUS: zaimportowano {} cen kwartalnych", imported);
} catch (IOException | RuntimeException ex) {
log.warn("GUS: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
return imported;
}
private List<String> citiesToTrack() {
Map<String, String> cities = new LinkedHashMap<>();
repository.findAll().stream()
.filter(price -> price.getSource() == Source.NBP)
.forEach(price -> cities.putIfAbsent(price.getCityKey(), price.getCity()));
listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(PropertyListing::getCity)
.filter(city -> city != null && !city.isBlank())
.forEach(city -> cities.putIfAbsent(PriceSnapshotService.key(city), city.trim()));
return List.copyOf(cities.values());
}
private InputStream download(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).GET().build();
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("serwer odpowiedzial " + response.statusCode());
}
if (response.body().length > MAX_DOWNLOAD_BYTES) {
throw new IOException("plik jest za duzy");
}
return new java.io.ByteArrayInputStream(response.body());
}
}
@@ -0,0 +1,97 @@
package pl.polskalokalnie.pricehistory;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.Instant;
/**
* Kwartalna srednia cena transakcyjna za m2 mieszkan z zewnetrznego zrodla:
* NBP (baza BaRN, 16 miast wojewodzkich i Gdynia) albo GUS (Bank Danych Lokalnych, miasta na prawach powiatu).
* Rynek: "Pierwotny", "Wtórny" albo "" (oba razem - tylko GUS).
*/
@Entity
@Table(name = "external_quarter_prices",
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "year", "quarter", "market", "cityKey"}))
public class ExternalQuarterPrice {
public enum Source { NBP, GUS }
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 8)
private Source source;
@Column(nullable = false)
private int year;
@Column(nullable = false)
private int quarter;
@Column(nullable = false, length = 32)
private String market;
@Column(nullable = false, length = 120)
private String cityKey;
@Column(nullable = false, length = 120)
private String city;
@Column(nullable = false)
private long pricePerM2;
@Column(nullable = false)
private Instant importedAt;
protected ExternalQuarterPrice() {
}
public ExternalQuarterPrice(Source source, int year, int quarter, String market, String city, long pricePerM2,
Instant importedAt) {
this.source = source;
this.year = year;
this.quarter = quarter;
this.market = market;
this.city = city;
this.cityKey = PriceSnapshotService.key(city);
this.pricePerM2 = pricePerM2;
this.importedAt = importedAt;
}
public Source getSource() {
return source;
}
public int getYear() {
return year;
}
public int getQuarter() {
return quarter;
}
public String getMarket() {
return market;
}
public String getCity() {
return city;
}
public String getCityKey() {
return cityKey;
}
public long getPricePerM2() {
return pricePerM2;
}
}
@@ -0,0 +1,26 @@
package pl.polskalokalnie.pricehistory;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source;
public interface ExternalQuarterPriceRepository extends JpaRepository<ExternalQuarterPrice, Long> {
List<ExternalQuarterPrice> findByCityKeyAndMarketOrderByYearAscQuarterAsc(String cityKey, String market);
@Query("select max(p.importedAt) from ExternalQuarterPrice p where p.source = :source")
Optional<Instant> findLastImport(@Param("source") Source source);
@Modifying
@Query("delete from ExternalQuarterPrice p where p.source = :source")
void deleteBySource(@Param("source") Source source);
@Modifying
@Query("delete from ExternalQuarterPrice p where p.source = :source and p.cityKey = :cityKey")
void deleteBySourceAndCityKey(@Param("source") Source source, @Param("cityKey") String cityKey);
}
@@ -0,0 +1,163 @@
package pl.polskalokalnie.pricehistory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
/**
* Publiczne API GUS - Bank Danych Lokalnych (bdl.stat.gov.pl, bez klucza).
* Temat P3794: srednia cena za 1 m2 lokali mieszkalnych sprzedanych w ramach transakcji rynkowych
* (dane kwartalne), poziom powiatu - miasta na prawach powiatu maja wlasne dane.
*/
@Component
public class GusBdlClient {
static final String SUBJECT = "P3794";
private static final String API = "https://bdl.stat.gov.pl/api/v1";
private static final Pattern QUARTER = Pattern.compile("^(\\d)\\s*kwarta");
/** Zmienna BDL: kwartal i rynek ("" = oba rynki razem). */
public record Variable(long id, int quarter, String market) {
}
public record QuarterValue(int year, int quarter, String market, long pricePerM2) {
}
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build();
private final ObjectMapper mapper = new ObjectMapper();
/** Id jednostki "Powiat m. <miasto>" / "Powiat m.st. <miasto>" albo null, gdy miasto nie jest na prawach powiatu. */
public String cityUnitId(String city) throws IOException, InterruptedException {
JsonNode results = get("/units/search?level=5&format=json&page-size=20&name="
+ URLEncoder.encode(city, StandardCharsets.UTF_8)).path("results");
return matchCityUnit(results, city);
}
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */
static final long WAGE_VARIABLE_ID = 64428;
public record AnnualValue(int year, java.math.BigDecimal value) {
}
/** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */
public AnnualValue latestAverageWage() throws IOException, InterruptedException {
JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100");
return parseLatestAnnualValue(response);
}
static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException {
AnnualValue latest = null;
for (JsonNode result : response.path("results")) {
for (JsonNode value : result.path("values")) {
int year = value.path("year").asInt(0);
java.math.BigDecimal amount = value.path("val").decimalValue();
if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) {
latest = new AnnualValue(year, amount);
}
}
}
if (latest == null) {
throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia");
}
return latest;
}
public List<Variable> variables() throws IOException, InterruptedException {
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
}
public List<QuarterValue> quarterlyPrices(String unitId, List<Variable> variables, int fromYear, int toYear)
throws IOException, InterruptedException {
StringBuilder query = new StringBuilder("/data/by-unit/").append(unitId).append("?format=json");
variables.forEach(variable -> query.append("&var-id=").append(variable.id()));
for (int year = fromYear; year <= toYear; year++) {
query.append("&year=").append(year);
}
return parseUnitData(get(query.toString()), variables);
}
static String matchCityUnit(JsonNode results, String city) {
String wanted = normalize(city);
for (JsonNode unit : results) {
String name = normalize(unit.path("name").asText());
// "Powiat m. Kraków", "Powiat m. st. Warszawa" - bez powiatow ziemskich wokol miasta.
if (name.equals("powiat m. " + wanted) || name.equals("powiat m. st. " + wanted)
|| name.equals("powiat m.st. " + wanted)) {
return unit.path("id").asText();
}
}
return null;
}
// Tylko "ogolem" wg powierzchni lokalu - dla kazdego kwartalu i rynku jedna wartosc.
static List<Variable> parseVariables(JsonNode results) {
List<Variable> variables = new ArrayList<>();
for (JsonNode variable : results) {
Matcher quarter = QUARTER.matcher(variable.path("n1").asText());
String market = switch (normalize(variable.path("n2").asText())) {
case "ogółem" -> "";
case "rynek pierwotny" -> "Pierwotny";
case "rynek wtórny" -> "Wtórny";
default -> null;
};
if (quarter.find() && market != null && "ogółem".equals(normalize(variable.path("n3").asText()))) {
variables.add(new Variable(variable.path("id").asLong(), Integer.parseInt(quarter.group(1)), market));
}
}
return variables;
}
static List<QuarterValue> parseUnitData(JsonNode response, List<Variable> variables) {
Map<Long, Variable> byId = new LinkedHashMap<>();
variables.forEach(variable -> byId.put(variable.id(), variable));
List<QuarterValue> values = new ArrayList<>();
for (JsonNode result : response.path("results")) {
Variable variable = byId.get(result.path("id").asLong());
if (variable == null) {
continue;
}
for (JsonNode value : result.path("values")) {
double price = value.path("val").asDouble(0);
int year = value.path("year").asInt(0);
if (price > 0 && year > 0) {
values.add(new QuarterValue(year, variable.quarter(), variable.market(), Math.round(price)));
}
}
}
return values;
}
private JsonNode get(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new IOException("GUS BDL odpowiedzial " + response.statusCode() + " dla " + path);
}
return mapper.readTree(response.body());
}
private static String normalize(String value) {
return Normalizer.normalize(value == null ? "" : value.trim(), Normalizer.Form.NFC)
.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}
@@ -0,0 +1,256 @@
package pl.polskalokalnie.pricehistory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
/**
* Odczyt cen transakcyjnych z pliku NBP ceny_mieszkan.xlsx bez zewnetrznych bibliotek (XLSX to ZIP z XML).
* Z arkuszy "Rynek pierwotny" i "Rynek wtórny" bierze blok "Ceny transakcyjne": kolumne kwartalu
* ("II 2026") i kolumny miast; pomija srednie zbiorcze ("7 miast") oraz indeksy cen.
*/
public final class NbpXlsxParser {
public record Row(int year, int quarter, String market, String city, long pricePerM2) {
}
private static final long MAX_ENTRY_BYTES = 20L * 1024 * 1024;
private static final Pattern QUARTER = Pattern.compile("^(IV|III|II|I)\\s+(\\d{4})$");
private static final Pattern CELL_REF = Pattern.compile("^([A-Z]+)\\d+$");
private static final Map<String, String> SHEET_MARKETS = Map.of(
"Rynek pierwotny", "Pierwotny",
"Rynek wtórny", "Wtórny");
private NbpXlsxParser() {
}
public static List<Row> parse(InputStream xlsx) throws IOException {
Map<String, byte[]> entries = unzip(xlsx);
try {
List<String> sharedStrings = sharedStrings(entries.get("xl/sharedStrings.xml"));
Map<String, String> sheetFiles = sheetFiles(entries);
List<Row> rows = new ArrayList<>();
for (Map.Entry<String, String> sheet : SHEET_MARKETS.entrySet()) {
String file = sheetFiles.get(sheet.getKey());
if (file != null && entries.containsKey(file)) {
rows.addAll(transactionPrices(read(entries.get(file)), sharedStrings, sheet.getValue()));
}
}
return rows;
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
throw new IOException("Nie udało się odczytać pliku NBP: " + ex.getMessage(), ex);
}
}
private static List<Row> transactionPrices(Document sheet, List<String> shared, String market) {
List<Map<String, String>> table = new ArrayList<>();
NodeList rowNodes = sheet.getElementsByTagName("row");
for (int i = 0; i < rowNodes.getLength(); i++) {
Map<String, String> cells = new LinkedHashMap<>();
NodeList cellNodes = ((Element) rowNodes.item(i)).getElementsByTagName("c");
for (int j = 0; j < cellNodes.getLength(); j++) {
Element cell = (Element) cellNodes.item(j);
Matcher ref = CELL_REF.matcher(cell.getAttribute("r"));
String value = cellValue(cell, shared);
if (ref.matches() && value != null && !value.isBlank()) {
cells.put(ref.group(1), value.trim());
}
}
table.add(cells);
}
String transactionColumn = findColumn(table, "Ceny transakcyjne");
Map<String, String> header = table.stream()
.filter(row -> row.values().stream().filter("Kwartał"::equals).count() >= 2)
.findFirst()
.orElse(null);
if (transactionColumn == null || header == null) {
return List.of();
}
// Kolumna kwartalu bloku transakcyjnego: ostatnia "Kwartał" nie dalej niz naglowek bloku.
String quarterColumn = null;
for (Map.Entry<String, String> cell : header.entrySet()) {
if ("Kwartał".equals(cell.getValue()) && columnIndex(cell.getKey()) <= columnIndex(transactionColumn)) {
quarterColumn = cell.getKey();
}
}
if (quarterColumn == null) {
return List.of();
}
Map<String, String> cityColumns = new LinkedHashMap<>();
for (int index = columnIndex(quarterColumn) + 1; ; index++) {
String name = header.get(columnName(index));
if (name == null || name.toLowerCase().contains("miast")) {
break;
}
cityColumns.put(columnName(index), name.replace("*", "").trim());
}
List<Row> result = new ArrayList<>();
for (Map<String, String> row : table) {
Matcher quarter = QUARTER.matcher(row.getOrDefault(quarterColumn, ""));
if (!quarter.matches()) {
continue;
}
int quarterNumber = switch (quarter.group(1)) {
case "I" -> 1;
case "II" -> 2;
case "III" -> 3;
default -> 4;
};
int year = Integer.parseInt(quarter.group(2));
cityColumns.forEach((column, city) -> {
Double price = number(row.get(column));
if (price != null && price > 0) {
result.add(new Row(year, quarterNumber, market, city, Math.round(price)));
}
});
}
return result;
}
private static String findColumn(List<Map<String, String>> table, String text) {
for (Map<String, String> row : table) {
for (Map.Entry<String, String> cell : row.entrySet()) {
if (text.equals(cell.getValue())) {
return cell.getKey();
}
}
}
return null;
}
private static String cellValue(Element cell, List<String> shared) {
String type = cell.getAttribute("t");
if ("inlineStr".equals(type)) {
return textOf(cell);
}
NodeList values = cell.getElementsByTagName("v");
if (values.getLength() == 0) {
return null;
}
String raw = values.item(0).getTextContent();
if ("s".equals(type)) {
int index = Integer.parseInt(raw.trim());
return index < shared.size() ? shared.get(index) : null;
}
return raw;
}
private static List<String> sharedStrings(byte[] xml) throws Exception {
List<String> strings = new ArrayList<>();
if (xml == null) {
return strings;
}
NodeList items = read(xml).getElementsByTagName("si");
for (int i = 0; i < items.getLength(); i++) {
strings.add(textOf((Element) items.item(i)));
}
return strings;
}
private static String textOf(Element element) {
StringBuilder text = new StringBuilder();
NodeList parts = element.getElementsByTagName("t");
for (int i = 0; i < parts.getLength(); i++) {
text.append(parts.item(i).getTextContent());
}
return text.toString();
}
// Nazwa arkusza -> sciezka pliku w ZIP (workbook.xml + relacje).
private static Map<String, String> sheetFiles(Map<String, byte[]> entries) throws Exception {
Map<String, String> targets = new HashMap<>();
NodeList relations = read(entries.get("xl/_rels/workbook.xml.rels")).getElementsByTagName("Relationship");
for (int i = 0; i < relations.getLength(); i++) {
Element relation = (Element) relations.item(i);
String target = relation.getAttribute("Target");
targets.put(relation.getAttribute("Id"), target.startsWith("/") ? target.substring(1) : "xl/" + target);
}
Map<String, String> files = new HashMap<>();
NodeList sheets = read(entries.get("xl/workbook.xml")).getElementsByTagName("sheet");
for (int i = 0; i < sheets.getLength(); i++) {
Element sheet = (Element) sheets.item(i);
files.put(sheet.getAttribute("name").trim(), targets.get(sheet.getAttribute("r:id")));
}
return files;
}
private static Map<String, byte[]> unzip(InputStream input) throws IOException {
Map<String, byte[]> entries = new HashMap<>();
try (ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
byte[] data = zip.readNBytes((int) MAX_ENTRY_BYTES + 1);
if (data.length > MAX_ENTRY_BYTES) {
throw new IOException("Plik NBP ma nieoczekiwanie duzy element: " + entry.getName());
}
entries.put(entry.getName(), data);
}
}
if (!entries.containsKey("xl/workbook.xml")) {
throw new IOException("To nie jest plik XLSX");
}
return entries;
}
private static Document read(byte[] xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// Plik z zewnatrz - bez encji zewnetrznych i DTD (ochrona przed XXE).
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(xml));
}
private static Double number(String value) {
if (value == null) {
return null;
}
try {
return Double.parseDouble(value.replace(',', '.'));
} catch (NumberFormatException ex) {
return null;
}
}
private static int columnIndex(String column) {
int index = 0;
for (char letter : column.toCharArray()) {
index = index * 26 + (letter - 'A' + 1);
}
return index;
}
private static String columnName(int index) {
StringBuilder name = new StringBuilder();
while (index > 0) {
int remainder = (index - 1) % 26;
name.insert(0, (char) ('A' + remainder));
index = (index - 1) / 26;
}
return name.toString();
}
}
@@ -0,0 +1,81 @@
package pl.polskalokalnie.pricehistory;
import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
/** Publiczna historia srednich cen ofertowych za m2 dla miasta lub dzielnicy. */
@RestController
@RequestMapping("/api/price-history")
public class PriceHistoryController {
private final PriceSnapshotRepository snapshotRepository;
private final PriceSnapshotService snapshotService;
private final ExternalQuarterPriceRepository externalRepository;
public PriceHistoryController(PriceSnapshotRepository snapshotRepository, PriceSnapshotService snapshotService,
ExternalQuarterPriceRepository externalRepository) {
this.snapshotRepository = snapshotRepository;
this.snapshotService = snapshotService;
this.externalRepository = externalRepository;
}
public record PricePoint(LocalDate date, long avgPricePerM2, int listingsCount) {
}
public record QuarterPoint(int year, int quarter, long pricePerM2) {
}
/**
* points - dzienne zapisy od "from"; current - srednia z ogloszen w tej chwili (null, gdy brak);
* trackedSince - od kiedy serwis w ogole zbiera historie (null, gdy jeszcze nic nie zapisano);
* nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta (puste, gdy zrodlo go nie obejmuje);
* nbpMarket - rynek, ktorego dotyczy nbp (NBP nie podaje obu rynkow razem - wtedy wtorny).
*/
public record PriceHistoryResponse(List<PricePoint> points, PricePoint current, LocalDate trackedSince,
List<QuarterPoint> nbp, List<QuarterPoint> gus, String nbpMarket) {
}
@GetMapping
public PriceHistoryResponse history(
@RequestParam String city,
@RequestParam(required = false, defaultValue = "") String district,
@RequestParam(required = false, defaultValue = "SALE") OfferType offerType,
@RequestParam(required = false, defaultValue = "APARTMENT") PropertyType propertyType,
@RequestParam(required = false, defaultValue = "") String market,
@RequestParam(required = false) LocalDate from
) {
LocalDate since = from != null ? from : PriceSnapshotService.today().minusYears(1);
String wantedMarket = market.trim();
List<PricePoint> points = snapshotRepository
.findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
PriceSnapshotService.key(city), PriceSnapshotService.key(district), offerType, propertyType,
wantedMarket, since)
.stream()
.map(snapshot -> new PricePoint(snapshot.getSnapshotDate(), snapshot.getAvgPricePerM2(), snapshot.getListingsCount()))
.toList();
PriceSnapshotService.Average now = snapshotService.currentAverage(offerType, propertyType, wantedMarket, city, district);
PricePoint current = now == null ? null
: new PricePoint(PriceSnapshotService.today(), now.avgPricePerM2(), now.listingsCount());
// NBP i GUS podaja ceny transakcyjne mieszkan - dla domow i najmu nie ma porownania.
boolean comparable = offerType == OfferType.SALE && propertyType == PropertyType.APARTMENT;
String nbpMarket = wantedMarket.isEmpty() ? "Wtórny" : wantedMarket;
String cityKey = PriceSnapshotService.key(city);
List<QuarterPoint> nbp = comparable ? quarters(cityKey, nbpMarket, ExternalQuarterPrice.Source.NBP) : List.of();
List<QuarterPoint> gus = comparable ? quarters(cityKey, wantedMarket, ExternalQuarterPrice.Source.GUS) : List.of();
return new PriceHistoryResponse(points, current, snapshotRepository.findFirstSnapshotDate().orElse(null),
nbp, gus, nbpMarket);
}
private List<QuarterPoint> quarters(String cityKey, String market, ExternalQuarterPrice.Source source) {
return externalRepository.findByCityKeyAndMarketOrderByYearAscQuarterAsc(cityKey, market).stream()
.filter(price -> price.getSource() == source)
.map(price -> new QuarterPoint(price.getYear(), price.getQuarter(), price.getPricePerM2()))
.toList();
}
}
@@ -0,0 +1,94 @@
package pl.polskalokalnie.pricehistory;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDate;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
/**
* Dzienny zapis sredniej ceny ofertowej za m2 z opublikowanych ogloszen - zrodlo historii cen.
* Jeden wiersz na (dzien, typ oferty, typ nieruchomosci, rynek, miasto, dzielnica).
* Pusta dzielnica = cale miasto, pusty rynek = wtorny i pierwotny razem.
* Klucze (cityKey, districtKey) sa znormalizowane, zeby "Kraków" i " kraków" trafialy w to samo.
*/
@Entity
@Table(name = "price_snapshots",
uniqueConstraints = @UniqueConstraint(columnNames = {
"snapshotDate", "offerType", "propertyType", "market", "cityKey", "districtKey"}),
indexes = @Index(columnList = "cityKey, districtKey, offerType, propertyType, market, snapshotDate"))
public class PriceSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private LocalDate snapshotDate;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private OfferType offerType;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private PropertyType propertyType;
@Column(nullable = false, length = 32)
private String market;
@Column(nullable = false, length = 120)
private String cityKey;
@Column(nullable = false, length = 120)
private String districtKey;
@Column(nullable = false, length = 120)
private String city;
@Column(nullable = false, length = 120)
private String district;
@Column(nullable = false)
private int listingsCount;
@Column(nullable = false)
private long avgPricePerM2;
protected PriceSnapshot() {
}
public PriceSnapshot(LocalDate snapshotDate, OfferType offerType, PropertyType propertyType, String market,
String city, String district, int listingsCount, long avgPricePerM2) {
this.snapshotDate = snapshotDate;
this.offerType = offerType;
this.propertyType = propertyType;
this.market = market;
this.city = city;
this.district = district;
this.cityKey = PriceSnapshotService.key(city);
this.districtKey = PriceSnapshotService.key(district);
this.listingsCount = listingsCount;
this.avgPricePerM2 = avgPricePerM2;
}
public LocalDate getSnapshotDate() {
return snapshotDate;
}
public int getListingsCount() {
return listingsCount;
}
public long getAvgPricePerM2() {
return avgPricePerM2;
}
}
@@ -0,0 +1,26 @@
package pl.polskalokalnie.pricehistory;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
public interface PriceSnapshotRepository extends JpaRepository<PriceSnapshot, Long> {
boolean existsBySnapshotDate(LocalDate snapshotDate);
@Modifying
@Query("delete from PriceSnapshot s where s.snapshotDate = :date")
void deleteBySnapshotDate(@Param("date") LocalDate date);
List<PriceSnapshot> findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc(
String cityKey, String districtKey, OfferType offerType, PropertyType propertyType, String market, LocalDate from);
@Query("select min(s.snapshotDate) from PriceSnapshot s")
Optional<LocalDate> findFirstSnapshotDate();
}
@@ -0,0 +1,160 @@
package pl.polskalokalnie.pricehistory;
import java.text.Normalizer;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
/**
* Historia cen: codzienny zapis srednich cen ofertowych za m2 z opublikowanych ogloszen.
* Danych sprzed pierwszego zapisu nie odtwarzamy - historia rosnie z kazdym dniem.
*/
@Service
public class PriceSnapshotService {
public static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
/** Wartosc rynku oznaczajaca "wtorny i pierwotny razem". */
public static final String ALL_MARKETS = "";
private static final Logger log = LoggerFactory.getLogger(PriceSnapshotService.class);
private final ListingRepository listingRepository;
private final PriceSnapshotRepository snapshotRepository;
public PriceSnapshotService(ListingRepository listingRepository, PriceSnapshotRepository snapshotRepository) {
this.listingRepository = listingRepository;
this.snapshotRepository = snapshotRepository;
}
/** Srednia na dany moment - ta sama grupa, co w zapisie dziennym. */
public record Average(long avgPricePerM2, int listingsCount) {
}
private record GroupKey(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
}
// Co noc zapisujemy stan z konca dnia; przy starcie uzupelniamy dzisiejszy zapis, jesli go brakuje.
// Transakcja na tych metodach, bo wywolanie recordToday() z wnetrza klasy omija proxy Springa.
@Scheduled(cron = "0 30 2 * * *", zone = "Europe/Warsaw")
@Transactional
public void recordDaily() {
recordToday();
}
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void recordOnStartupIfMissing() {
if (!snapshotRepository.existsBySnapshotDate(today())) {
recordToday();
}
}
/** Zapis dzisiejszych srednich; ponowne wywolanie w tym samym dniu nadpisuje dzisiejsze wiersze. */
@Transactional
public int recordToday() {
LocalDate date = today();
snapshotRepository.deleteBySnapshotDate(date);
Map<GroupKey, List<Double>> groups = group(pricedListings());
List<PriceSnapshot> rows = new ArrayList<>();
groups.forEach((key, prices) -> rows.add(new PriceSnapshot(date, key.offerType(), key.propertyType(), key.market(),
key.city(), key.district(), prices.size(), Math.round(average(prices)))));
snapshotRepository.saveAll(rows);
log.info("Historia cen: zapisano {} srednich za {}", rows.size(), date);
return rows.size();
}
/** Aktualna srednia liczona wprost z ogloszen - "dzisiaj" na wykresie jest zawsze swieze. */
public Average currentAverage(OfferType offerType, PropertyType propertyType, String market, String city, String district) {
String wantedCity = key(city);
String wantedDistrict = key(district);
List<Double> prices = pricedListings().stream()
.filter(listing -> listing.getOfferType() == offerType && listing.getPropertyType() == propertyType)
.filter(listing -> key(listing.getCity()).equals(wantedCity))
.filter(listing -> wantedDistrict.isEmpty() || key(listing.getDistrict()).equals(wantedDistrict))
.filter(listing -> market.isEmpty() || market.equalsIgnoreCase(trim(listing.getMarket())))
.map(PriceSnapshotService::pricePerM2)
.toList();
return prices.isEmpty() ? null : new Average(Math.round(average(prices)), prices.size());
}
private List<PropertyListing> pricedListings() {
return listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> listing.getOfferType() != null && listing.getPropertyType() != null)
.filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0)
.filter(listing -> listing.getArea() != null && listing.getArea() > 0)
.filter(listing -> !trim(listing.getCity()).isEmpty())
.toList();
}
// Kazde ogloszenie liczy sie do miasta i (jesli ma) do dzielnicy - dla wszystkich rynkow i dla swojego rynku.
private static Map<GroupKey, List<Double>> group(List<PropertyListing> listings) {
Map<GroupKey, List<Double>> groups = new LinkedHashMap<>();
for (PropertyListing listing : listings) {
double price = pricePerM2(listing);
String city = trim(listing.getCity());
String district = trim(listing.getDistrict());
String market = trim(listing.getMarket());
List<String> markets = market.isEmpty() ? List.of(ALL_MARKETS) : List.of(ALL_MARKETS, market);
List<String> districts = district.isEmpty() ? List.of("") : List.of("", district);
for (String groupMarket : markets) {
for (String groupDistrict : districts) {
GroupKey key = new GroupKey(listing.getOfferType(), listing.getPropertyType(), groupMarket,
city, groupDistrict);
groups.computeIfAbsent(canonical(groups, key), ignored -> new ArrayList<>()).add(price);
}
}
}
return groups;
}
// Ta sama grupa niezaleznie od wielkosci liter i spacji - pierwsza napotkana pisownia zostaje nazwa.
private static GroupKey canonical(Map<GroupKey, List<Double>> groups, GroupKey candidate) {
for (GroupKey existing : groups.keySet()) {
if (existing.offerType() == candidate.offerType() && existing.propertyType() == candidate.propertyType()
&& existing.market().equalsIgnoreCase(candidate.market())
&& key(existing.city()).equals(key(candidate.city()))
&& key(existing.district()).equals(key(candidate.district()))) {
return existing;
}
}
return candidate;
}
public static LocalDate today() {
return LocalDate.now(ZONE);
}
/** Klucz porownania nazw: male litery, bez nadmiarowych spacji (polskie znaki zostaja). */
public static String key(String value) {
return Normalizer.normalize(trim(value), Normalizer.Form.NFC).toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static double pricePerM2(PropertyListing listing) {
return listing.getPrice().doubleValue() / listing.getArea();
}
private static double average(List<Double> values) {
return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
}
}
@@ -1,56 +1,35 @@
package pl.polskalokalnie.promotion; package pl.polskalokalnie.promotion;
import java.util.Map; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/** /**
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification). * Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
* *
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje * Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie * kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay. * AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
*/ */
@RestController @RestController
@RequestMapping("/api/payments/autopay") @RequestMapping("/api/payments/autopay")
public class AutoPayItnController { public class AutoPayItnController {
private final AutoPayService autoPayService; private final AutoPayItnService itnService;
private final PaymentConfigService paymentConfigService;
private final PromotionService promotionService;
public AutoPayItnController(AutoPayService autoPayService, public AutoPayItnController(AutoPayItnService itnService) {
PaymentConfigService paymentConfigService, this.itnService = itnService;
PromotionService promotionService) {
this.autoPayService = autoPayService;
this.paymentConfigService = paymentConfigService;
this.promotionService = promotionService;
} }
@PostMapping("/itn") /** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) { @PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
PaymentConfig cfg = paymentConfigService.get(); public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
if (!autoPayService.verifyItn(params, cfg)) { return ResponseEntity.ok()
return ResponseEntity.badRequest().body("ERROR: invalid hash"); .contentType(MediaType.APPLICATION_XML)
} .body(itnService.handle(transactions));
String orderId = params.get("OrderID");
if (orderId != null && isSuccess(params)) {
promotionService.markPaidByExternalId(orderId);
}
return ResponseEntity.ok("OK");
}
private static boolean isSuccess(Map<String, String> params) {
String status = params.getOrDefault("PaymentStatus",
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
String s = status == null ? "" : status.trim().toUpperCase();
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
} }
} }
@@ -0,0 +1,177 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayItnParser;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
*
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
* 2. zgodnosc identyfikatora serwisu,
* 3. istnienie zamowienia o danym OrderID,
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
*
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
*/
@Service
public class AutoPayItnService {
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
private static final String CONFIRMED = "CONFIRMED";
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
private static final String NL = "\n";
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final PromotionOrderRepository orderRepository;
private final PromotionService promotionService;
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
PromotionOrderRepository orderRepository, PromotionService promotionService) {
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.orderRepository = orderRepository;
this.promotionService = promotionService;
}
/**
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
*
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
*/
@Transactional
public String handle(String transactionsBase64) {
PaymentConfig cfg = paymentConfigService.get();
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
boolean authentic = verifySignature(cfg, itn);
if (!authentic) {
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
}
List<Confirmation> confirmations = new ArrayList<>();
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
String result = authentic ? process(tx) : NOT_CONFIRMED;
confirmations.add(new Confirmation(tx.orderId(), result));
}
return response(cfg, confirmations);
}
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
return false;
}
String expected = autoPayService.hash(cfg, itn.signedValues());
return AutoPayService.hashMatches(expected, itn.hash());
}
private String process(AutoPayItnParser.ItnTransaction tx) {
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
if (found.isEmpty()) {
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
return NOT_CONFIRMED;
}
PromotionOrder order = found.get();
if (!tx.success()) {
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
// ale niczego nie naliczamy.
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
order.setStatus(OrderStatus.FAILED);
orderRepository.save(order);
}
return CONFIRMED;
}
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
// ale nie nalicza drugi raz kredytow ani wyroznienia.
if (order.getStatus() == OrderStatus.PAID) {
return CONFIRMED;
}
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
return NOT_CONFIRMED;
}
promotionService.applyPaid(order);
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
return CONFIRMED;
}
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
static boolean amountMatches(BigDecimal expected, String actual) {
if (expected == null || actual == null) {
return false;
}
try {
return expected.compareTo(new BigDecimal(actual)) == 0;
} catch (NumberFormatException e) {
return false;
}
}
private record Confirmation(String orderId, String result) {
}
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
List<String> values = new ArrayList<>();
values.add(serviceId);
for (Confirmation c : confirmations) {
values.add(c.orderId());
values.add(c.result());
}
String hash = autoPayService.hash(cfg, values);
StringBuilder entries = new StringBuilder();
for (Confirmation c : confirmations) {
entries.append(" <transactionConfirmed>").append(NL)
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
.append(" </transactionConfirmed>").append(NL);
}
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
+ "<confirmationList>" + NL
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
+ " <transactionsConfirmations>" + NL
+ entries
+ " </transactionsConfirmations>" + NL
+ " <hash>" + hash + "</hash>" + NL
+ "</confirmationList>";
}
private static String decode(String base64) {
try {
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
}
private static String escape(String value) {
return value == null ? "" : value.replace("&", "&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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayStartForm;
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */ /** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
@RestController @RestController
@@ -51,6 +52,11 @@ public class PromotionController {
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id)); return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
} }
@GetMapping("/order/{id}/autopay-form")
public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) {
return promotionService.autoPayForm(authentication.getName(), id);
}
public record CreditsResponse(int credits) { public record CreditsResponse(int credits) {
} }
@@ -1,10 +1,22 @@
package pl.polskalokalnie.promotion; package pl.polskalokalnie.promotion;
import jakarta.persistence.LockModeType;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> { public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
Optional<PromotionOrder> findByExternalId(String externalId); Optional<PromotionOrder> findByExternalId(String externalId);
List<PromotionOrder> findByOrderByCreatedAtDesc(); List<PromotionOrder> findByOrderByCreatedAtDesc();
/**
* Zamowienie z blokada wiersza do konca transakcji - rownolegle notyfikacje ITN o tym samym
* zamowieniu obsluguja sie po kolei, wiec kredyty nie zostana naliczone dwa razy.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from PromotionOrder o where o.externalId = :externalId")
Optional<PromotionOrder> findForUpdateByExternalId(@Param("externalId") String externalId);
} }
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository; import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.PropertyListing; import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService; import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.AutoPayStartForm;
import pl.polskalokalnie.payment.PaymentConfig; import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService; import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
@@ -27,7 +28,7 @@ public class PromotionService {
private final ListingRepository listingRepository; private final ListingRepository listingRepository;
private final PaymentConfigService paymentConfigService; private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService; private final AutoPayService autoPayService;
private final String baseUrl; private final boolean autoConfirmPayments;
public PromotionService(PromotionPlanRepository planRepository, public PromotionService(PromotionPlanRepository planRepository,
PromotionPackageRepository packageRepository, PromotionPackageRepository packageRepository,
@@ -36,7 +37,7 @@ public class PromotionService {
ListingRepository listingRepository, ListingRepository listingRepository,
PaymentConfigService paymentConfigService, PaymentConfigService paymentConfigService,
AutoPayService autoPayService, AutoPayService autoPayService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) { @Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
this.planRepository = planRepository; this.planRepository = planRepository;
this.packageRepository = packageRepository; this.packageRepository = packageRepository;
this.orderRepository = orderRepository; this.orderRepository = orderRepository;
@@ -44,7 +45,7 @@ public class PromotionService {
this.listingRepository = listingRepository; this.listingRepository = listingRepository;
this.paymentConfigService = paymentConfigService; this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService; this.autoPayService = autoPayService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", ""); this.autoConfirmPayments = autoConfirmPayments;
} }
// --- Publiczne (uzytkownik) --- // --- Publiczne (uzytkownik) ---
@@ -63,6 +64,14 @@ public class PromotionService {
@Transactional @Transactional
public CheckoutResponse checkout(String email, CheckoutRequest request) { public CheckoutResponse checkout(String email, CheckoutRequest request) {
PaymentConfig cfg = paymentConfigService.get();
// Bez skonfigurowanej bramki nie ma komu zaplacic - zamowienie uznane "od reki" oznaczaloby
// darmowe promowanie dla kazdego. Symulacja platnosci tylko przy jawnie wlaczonym APP_PAYMENTS_AUTO_CONFIRM (lokalnie).
if (!cfg.isLive() && !autoConfirmPayments) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
}
PromotionOrder order = new PromotionOrder(); PromotionOrder order = new PromotionOrder();
order.setUserEmail(email); order.setUserEmail(email);
order.setKind(request.kind()); order.setKind(request.kind());
@@ -70,8 +79,7 @@ public class PromotionService {
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim()); order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
if (request.kind() == PromotionKind.PLAN) { if (request.kind() == PromotionKind.PLAN) {
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId")) PromotionPlan plan = requireActivePlan(request.planId());
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PropertyListing listing = requireOwnedListing(request.listingId(), email); PropertyListing listing = requireOwnedListing(request.listingId(), email);
order.setRefId(plan.getId()); order.setRefId(plan.getId());
order.setItemName("Promowanie: " + plan.getName()); order.setItemName("Promowanie: " + plan.getName());
@@ -79,6 +87,7 @@ public class PromotionService {
order.setListingId(listing.getId()); order.setListingId(listing.getId());
} else { } else {
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId")) PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
.filter(PromotionPackage::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
order.setRefId(pack.getId()); order.setRefId(pack.getId());
order.setItemName("Pakiet promowan: " + pack.getName()); order.setItemName("Pakiet promowan: " + pack.getName());
@@ -86,16 +95,43 @@ public class PromotionService {
} }
order = orderRepository.save(order); order = orderRepository.save(order);
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) { if (!cfg.isLive()) {
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay). // Lokalny tryb bez bramki (app.payments.auto-confirm) - platnosc uznajemy od razu.
applyPaid(order); applyPaid(order);
return CheckoutResponse.paid(order.getId()); return CheckoutResponse.paid(order.getId());
} }
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId(); // Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(), // do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
order.getItemName(), email, returnUrl); return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
return CheckoutResponse.redirect(order.getId(), url); }
/** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */
public AutoPayStartForm autoPayForm(String email, Long orderId) {
PromotionOrder order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
if (!order.getUserEmail().equalsIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
}
if (order.getStatus() != OrderStatus.PENDING) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność");
}
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
}
return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(),
paymentDescription(order), order.getUserEmail());
}
/**
* Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki
* alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu.
*/
static String paymentDescription(PromotionOrder order) {
return order.getKind() == PromotionKind.PLAN
? "Promowanie ogloszenia nr " + order.getListingId()
: "Pakiet promowan, zamowienie nr " + order.getId();
} }
@Transactional @Transactional
@@ -115,33 +151,24 @@ public class PromotionService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail()) AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity()); userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
userRepository.save(user);
} }
order.setStatus(OrderStatus.PAID); order.setStatus(OrderStatus.PAID);
order.setPaidAt(Instant.now()); order.setPaidAt(Instant.now());
orderRepository.save(order); orderRepository.save(order);
} }
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
@Transactional
public void markPaidByExternalId(String externalId) {
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
}
@Transactional @Transactional
public int promoteWithCredit(String email, CreditPromoteRequest request) { public int promoteWithCredit(String email, CreditPromoteRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email) AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
if (user.getPromotionCredits() <= 0) { PromotionPlan plan = requireActivePlan(request.planId());
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
PromotionPlan plan = planRepository.findById(request.planId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PropertyListing listing = requireOwnedListing(request.listingId(), email); PropertyListing listing = requireOwnedListing(request.listingId(), email);
user.setPromotionCredits(user.getPromotionCredits() - 1); // Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
userRepository.save(user); if (userRepository.consumePromotionCredit(user.getId()) == 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
extendPromotion(listing, plan.getDurationDays()); extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing); listingRepository.save(listing);
@@ -158,7 +185,15 @@ public class PromotionService {
order.setPaidAt(Instant.now()); order.setPaidAt(Instant.now());
orderRepository.save(order); orderRepository.save(order);
return user.getPromotionCredits(); return credits(email);
}
// Kupic (i wydac na nie kredyt) mozna tylko plan wystawiony w ofercie - wylaczony przez
// administracje plan (np. o starej cenie) jest niedostepny takze przy wywolaniu API wprost.
private PromotionPlan requireActivePlan(Long planId) {
return planRepository.findById(requireId(planId, "planId"))
.filter(PromotionPlan::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
} }
public String orderStatus(String email, Long id) { public String orderStatus(String email, Long id) {
@@ -284,7 +319,8 @@ public class PromotionService {
return id; return id;
} }
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
private static String generateExternalId() { private static String generateExternalId() {
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000)); return java.util.UUID.randomUUID().toString().replace("-", "");
} }
} }
@@ -19,20 +19,24 @@ public class ListingReportController {
this.userReportService = userReportService; this.userReportService = userReportService;
} }
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
// zgloszonego) widzi tylko administracja.
@PostMapping("/listings") @PostMapping("/listings")
public ListingReportResponse createListingReport( public ReportReceiptResponse createListingReport(
@Valid @RequestBody CreateListingReportRequest request, @Valid @RequestBody CreateListingReportRequest request,
Authentication authentication Authentication authentication
) { ) {
return listingReportService.create(request, authentication.getName()); ListingReportResponse report = listingReportService.create(request, authentication.getName());
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
} }
// Zgloszenie uzytkownika z rozmowy w wiadomosciach. // Zgloszenie uzytkownika z rozmowy w wiadomosciach.
@PostMapping("/users") @PostMapping("/users")
public UserReportResponse createUserReport( public ReportReceiptResponse createUserReport(
@Valid @RequestBody CreateUserReportRequest request, @Valid @RequestBody CreateUserReportRequest request,
Authentication authentication Authentication authentication
) { ) {
return userReportService.create(request, authentication.getName()); UserReportResponse report = userReportService.create(request, authentication.getName());
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
} }
} }
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository; import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing; import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserRepository;
@@ -30,7 +31,10 @@ public class ListingReportService {
@Transactional @Transactional
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) { public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
// Zglaszac mozna tylko opublikowane ogloszenia - inaczej kolejne numery pozwalaly sprawdzac
// istnienie ogloszen oczekujacych i odrzuconych.
PropertyListing listing = listingRepository.findById(request.listingId()) PropertyListing listing = listingRepository.findById(request.listingId())
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
ListingReport report = new ListingReport(); ListingReport report = new ListingReport();
@@ -0,0 +1,10 @@
package pl.polskalokalnie.report;
import java.time.Instant;
/**
* Potwierdzenie przyjecia zgloszenia dla zglaszajacego. Celowo bez danych zgloszonego (e-mail,
* nazwa, czy ma konto) i ogloszenia - pelne zgloszenie widzi tylko administracja.
*/
public record ReportReceiptResponse(Long id, Instant createdAt, String reasonTitle, ListingReportStatus status) {
}
@@ -7,6 +7,11 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.Message;
import pl.polskalokalnie.message.MessageRepository;
import pl.polskalokalnie.user.AppUser; import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role; import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository; import pl.polskalokalnie.user.UserRepository;
@@ -16,14 +21,21 @@ public class UserReportService {
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem. // Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
private static final int MAX_CONVERSATION_MESSAGES = 50; private static final int MAX_CONVERSATION_MESSAGES = 50;
private static final int MAX_MESSAGE_LENGTH = 2000;
private final UserReportRepository userReportRepository; private final UserReportRepository userReportRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final ListingService listingService;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository) { public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
ConversationRepository conversationRepository, MessageRepository messageRepository,
ListingService listingService) {
this.userReportRepository = userReportRepository; this.userReportRepository = userReportRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.listingService = listingService;
} }
@Transactional @Transactional
@@ -33,7 +45,6 @@ public class UserReportService {
report.setConversationId(ReportAttachments.trimToNull(request.conversationId())); report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
report.setListingId(request.listingId()); report.setListingId(request.listingId());
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle())); report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
report.setConversation(cleanConversation(request.conversation()));
report.setReasonId(request.reasonId().trim()); report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim()); report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(ReportAttachments.trimToNull(request.details())); report.setDetails(ReportAttachments.trimToNull(request.details()));
@@ -42,9 +53,21 @@ public class UserReportService {
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null)); report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
report.setStatus(ListingReportStatus.OPEN); report.setStatus(ListingReportStatus.OPEN);
// Konto zgloszonego wiazemy tylko wtedy, gdy naprawde istnieje - rozmowy demonstracyjne // Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
// nie maja odpowiednika w bazie i zostaja opisane sama nazwa z watku. // a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
AppUser reported = findReported(request.reportedEmail()); // W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje, a tresci rozmowy
// przyslanej przez przegladarke nie zapisujemy - administracja wzielaby ja za dowod.
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
Conversation conversation = reporterConversation(request.conversationId(), reporter);
AppUser reported;
if (conversation != null) {
reported = userRepository.findById(conversation.otherParticipant(reporter.getId())).orElse(null);
report.setListingId(conversation.getListingId());
report.setListingTitle(conversation.getListingTitle());
report.setConversation(conversationFromDatabase(conversation, reporter.getId()));
} else {
reported = findReported(request.reportedEmail());
}
if (reported != null) { if (reported != null) {
report.setReportedEmail(reported.getEmail()); report.setReportedEmail(reported.getEmail());
report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName()); report.setReportedName(reported.getFullName() != null ? reported.getFullName() : report.getReportedName());
@@ -83,6 +106,7 @@ public class UserReportService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora"); throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
} }
listingService.deleteAllOfOwner(reported.getEmail());
userRepository.delete(reported); userRepository.delete(reported);
report.setReportedUserDeleted(true); report.setReportedUserDeleted(true);
@@ -95,32 +119,31 @@ public class UserReportService {
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail())); return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
} }
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia. // Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) { private Conversation reporterConversation(String conversationId, AppUser reporter) {
if (payloads == null || payloads.isEmpty()) { if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
return List.of(); return null;
} }
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES return conversationRepository.findById(Long.valueOf(conversationId.trim()))
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size()) .filter(conversation -> conversation.isParticipant(reporter.getId()))
: payloads; .orElse(null);
}
List<UserReportMessage> cleaned = new ArrayList<>(); private List<UserReportMessage> conversationFromDatabase(Conversation conversation, Long reporterId) {
for (UserReportMessagePayload payload : tail) { List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(conversation.getId());
if (payload == null) { List<Message> tail = messages.size() > MAX_CONVERSATION_MESSAGES
continue; ? messages.subList(messages.size() - MAX_CONVERSATION_MESSAGES, messages.size())
} : messages;
String content = payload.content() == null ? "" : payload.content().trim(); List<UserReportMessage> snapshot = new ArrayList<>();
if (content.length() > MAX_MESSAGE_LENGTH) { for (Message source : tail) {
content = content.substring(0, MAX_MESSAGE_LENGTH);
}
UserReportMessage message = new UserReportMessage(); UserReportMessage message = new UserReportMessage();
message.setFromReporter(payload.fromReporter()); message.setFromReporter(source.getSenderId().equals(reporterId));
message.setContent(content); message.setContent(source.getContent());
message.setSentAt(payload.sentAt()); message.setSentAt(source.getCreatedAt());
message.setHasPhoto(payload.hasPhoto()); message.setHasPhoto(false);
cleaned.add(message); snapshot.add(message);
} }
return cleaned; return snapshot;
} }
private AppUser findReported(String email) { private AppUser findReported(String email) {
@@ -0,0 +1,193 @@
package pl.polskalokalnie.search;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Zapisane wyszukiwanie uzytkownika ("Zapisz wyszukiwanie" na liscie ofert). Wczesniej
* zylo tylko w localStorage przegladarki, wiec ginelo po zmianie urzadzenia albo wyczyszczeniu
* danych. Nie wysylamy z niego zadnych powiadomien - to lista do wygodnego wracania do filtrow.
*/
@Entity
@Table(name = "saved_searches")
public class SavedSearch {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
/** "buy" albo "rent" - te same wartosci co w adresach /kupno i /wynajem. */
@Column(nullable = false, length = 10)
private String mode;
@Column(length = 120)
private String city;
@Column(name = "property_type", length = 60)
private String propertyType;
@Column(name = "price_min")
private Long priceMin;
@Column(name = "price_max")
private Long priceMax;
@Column(name = "area_min")
private Integer areaMin;
@Column(name = "area_max")
private Integer areaMax;
private Integer rooms;
@Column(name = "secondary_market", nullable = false)
private boolean secondaryMarket;
@Column(name = "primary_market", nullable = false)
private boolean primaryMarket;
@Column(name = "no_fee_only", nullable = false)
private boolean noFeeOnly;
/** Pozostale filtry ("wiecej filtrow") jako JSON - jest ich wiele i zmieniaja sie z widokiem. */
@Column(name = "more_filters", columnDefinition = "text")
private String moreFilters;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getPropertyType() {
return propertyType;
}
public void setPropertyType(String propertyType) {
this.propertyType = propertyType;
}
public Long getPriceMin() {
return priceMin;
}
public void setPriceMin(Long priceMin) {
this.priceMin = priceMin;
}
public Long getPriceMax() {
return priceMax;
}
public void setPriceMax(Long priceMax) {
this.priceMax = priceMax;
}
public Integer getAreaMin() {
return areaMin;
}
public void setAreaMin(Integer areaMin) {
this.areaMin = areaMin;
}
public Integer getAreaMax() {
return areaMax;
}
public void setAreaMax(Integer areaMax) {
this.areaMax = areaMax;
}
public Integer getRooms() {
return rooms;
}
public void setRooms(Integer rooms) {
this.rooms = rooms;
}
public boolean isSecondaryMarket() {
return secondaryMarket;
}
public void setSecondaryMarket(boolean secondaryMarket) {
this.secondaryMarket = secondaryMarket;
}
public boolean isPrimaryMarket() {
return primaryMarket;
}
public void setPrimaryMarket(boolean primaryMarket) {
this.primaryMarket = primaryMarket;
}
public boolean isNoFeeOnly() {
return noFeeOnly;
}
public void setNoFeeOnly(boolean noFeeOnly) {
this.noFeeOnly = noFeeOnly;
}
public String getMoreFilters() {
return moreFilters;
}
public void setMoreFilters(String moreFilters) {
this.moreFilters = moreFilters;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,151 @@
package pl.polskalokalnie.search;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
/**
* Zapisane wyszukiwania zalogowanego uzytkownika. Dostep wymaga logowania (regula
* anyRequest().authenticated() w SecurityConfig), a kazda operacja dotyczy wylacznie
* wpisow wlasciciela konta. Zadnych powiadomien - to tylko lista zapisanych filtrow.
*/
@RestController
@RequestMapping("/api/saved-searches")
public class SavedSearchController {
/** Tyle samo, ile dotad miescil zapis w przegladarce - chroni przed zasypaniem listy. */
static final int LIMIT = 60;
private final SavedSearchRepository repository;
public SavedSearchController(SavedSearchRepository repository) {
this.repository = repository;
}
/** Kryteria wyszukiwania - dokladnie to, co pokazuje pasek filtrow na liscie ofert. */
public record SavedSearchRequest(String mode, String city, String propertyType, Long priceMin, Long priceMax,
Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny, boolean pierwotny,
boolean noFeeOnly, String more) {
}
public record SavedSearchResponse(Long id, String mode, String city, String propertyType, Long priceMin,
Long priceMax, Integer areaMin, Integer areaMax, Integer rooms, boolean wtorny,
boolean pierwotny, boolean noFeeOnly, String more, Instant createdAt,
Instant updatedAt) {
}
@GetMapping
public List<SavedSearchResponse> list(Authentication authentication) {
return repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(authentication.getName()).stream()
.map(SavedSearchController::toResponse)
.toList();
}
@PostMapping
@Transactional
public SavedSearchResponse add(@RequestBody SavedSearchRequest request, Authentication authentication) {
if (request == null || request.mode() == null || request.mode().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak trybu wyszukiwania");
}
String userEmail = authentication.getName();
List<SavedSearch> existing = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
SavedSearch duplicate = existing.stream().filter(saved -> sameCriteria(saved, request)).findFirst().orElse(null);
if (duplicate != null) {
return toResponse(duplicate);
}
// Najstarsze wpisy ponad limit usuwamy, zeby zapis zawsze sie udal.
existing.stream().skip(LIMIT - 1L).forEach(repository::delete);
return toResponse(repository.save(fromRequest(request, userEmail)));
}
/**
* Przeniesienie wyszukiwan zapisanych wczesniej w przegladarce. Dodajemy tylko te,
* ktorych uzytkownik nie ma jeszcze na koncie - import mozna powtorzyc bez duplikatow.
*/
@PostMapping("/import")
@Transactional
public List<SavedSearchResponse> importLocal(@RequestBody List<SavedSearchRequest> requests, Authentication authentication) {
String userEmail = authentication.getName();
if (requests != null) {
for (SavedSearchRequest request : requests) {
if (request == null || request.mode() == null || request.mode().isBlank()) {
continue;
}
List<SavedSearch> current = repository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail);
if (current.size() >= LIMIT || current.stream().anyMatch(saved -> sameCriteria(saved, request))) {
continue;
}
repository.save(fromRequest(request, userEmail));
}
}
return list(authentication);
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Void> remove(@PathVariable Long id, Authentication authentication) {
repository.findById(id)
.filter(saved -> saved.getUserEmail().equalsIgnoreCase(authentication.getName()))
.ifPresent(repository::delete);
return ResponseEntity.noContent().build();
}
private static SavedSearch fromRequest(SavedSearchRequest request, String userEmail) {
SavedSearch saved = new SavedSearch();
Instant now = Instant.now();
saved.setUserEmail(userEmail);
saved.setMode(request.mode());
saved.setCity(request.city());
saved.setPropertyType(request.propertyType());
saved.setPriceMin(request.priceMin());
saved.setPriceMax(request.priceMax());
saved.setAreaMin(request.areaMin());
saved.setAreaMax(request.areaMax());
saved.setRooms(request.rooms());
saved.setSecondaryMarket(request.wtorny());
saved.setPrimaryMarket(request.pierwotny());
saved.setNoFeeOnly(request.noFeeOnly());
saved.setMoreFilters(request.more());
saved.setCreatedAt(now);
saved.setUpdatedAt(now);
return saved;
}
static boolean sameCriteria(SavedSearch saved, SavedSearchRequest request) {
return Objects.equals(saved.getMode(), request.mode())
&& Objects.equals(trim(saved.getCity()), trim(request.city()))
&& Objects.equals(trim(saved.getPropertyType()), trim(request.propertyType()))
&& Objects.equals(saved.getPriceMin(), request.priceMin())
&& Objects.equals(saved.getPriceMax(), request.priceMax())
&& Objects.equals(saved.getAreaMin(), request.areaMin())
&& Objects.equals(saved.getAreaMax(), request.areaMax())
&& Objects.equals(saved.getRooms(), request.rooms())
&& saved.isSecondaryMarket() == request.wtorny()
&& saved.isPrimaryMarket() == request.pierwotny()
&& saved.isNoFeeOnly() == request.noFeeOnly()
&& Objects.equals(trim(saved.getMoreFilters()), trim(request.more()));
}
private static String trim(String value) {
return value == null ? "" : value.trim();
}
private static SavedSearchResponse toResponse(SavedSearch saved) {
return new SavedSearchResponse(saved.getId(), saved.getMode(), saved.getCity(), saved.getPropertyType(),
saved.getPriceMin(), saved.getPriceMax(), saved.getAreaMin(), saved.getAreaMax(), saved.getRooms(),
saved.isSecondaryMarket(), saved.isPrimaryMarket(), saved.isNoFeeOnly(), saved.getMoreFilters(),
saved.getCreatedAt(), saved.getUpdatedAt());
}
}
@@ -0,0 +1,170 @@
package pl.polskalokalnie.search;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Powiadomienia o nowych ofertach pasujacych do zapisanych wyszukiwan.
*
* Trafiaja wylacznie na liste powiadomien w serwisie (/powiadomienia) - maili z nich nie
* wysylamy. Wywolywane, gdy ogloszenie staje sie widoczne publicznie (moderacja zatwierdza
* albo wlasciciel wznawia wstrzymane ogloszenie). Klucz deduplikacji pilnuje, zeby to samo
* ogloszenie nie wrocilo do tego samego wyszukiwania po kolejnej zmianie statusu.
*/
@Service
public class SavedSearchNotifier {
private static final Logger log = LoggerFactory.getLogger(SavedSearchNotifier.class);
private final SavedSearchRepository savedSearchRepository;
private final UserSettingsRepository userSettingsRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
public SavedSearchNotifier(SavedSearchRepository savedSearchRepository,
UserSettingsRepository userSettingsRepository, UserRepository userRepository,
NotificationService notificationService) {
this.savedSearchRepository = savedSearchRepository;
this.userSettingsRepository = userSettingsRepository;
this.userRepository = userRepository;
this.notificationService = notificationService;
}
public void notifyMatching(PropertyListing listing) {
if (listing == null || listing.getId() == null) {
return;
}
List<SavedSearch> searches;
try {
searches = savedSearchRepository.findAll();
} catch (RuntimeException ex) {
log.warn("Nie udalo sie odczytac zapisanych wyszukiwan: {}", ex.getMessage());
return;
}
for (SavedSearch search : searches) {
String email = search.getUserEmail();
if (email == null || email.equalsIgnoreCase(listing.getOwnerEmail())) {
// Wlasciciel nie dostaje powiadomienia o wlasnym ogloszeniu.
continue;
}
if (!matches(search, listing) || !wantsNotification(email)) {
continue;
}
notificationService.create(email, "listing", "search",
"Nowa oferta z zapisanego wyszukiwania",
listing.getTitle() + " - " + listing.getCity() + ", " + price(listing),
"listing:" + listing.getId(),
"saved-search:" + search.getId() + ":" + listing.getId());
}
}
private boolean wantsNotification(String email) {
return userRepository.findByEmailIgnoreCase(email)
.flatMap(user -> userSettingsRepository.findByUserId(user.getId()))
.map(UserSettings::isNotifySavedSearches)
.orElse(true);
}
/**
* Te same kryteria, ktore filtruja liste ofert. Puste pole znaczy "bez ograniczenia",
* wiec brak wartosci nigdy nie odrzuca ogloszenia.
*/
boolean matches(SavedSearch search, PropertyListing listing) {
OfferType wanted = "rent".equalsIgnoreCase(search.getMode()) ? OfferType.RENT : OfferType.SALE;
if (listing.getOfferType() != wanted) {
return false;
}
if (notBlank(search.getCity()) && !search.getCity().trim().equalsIgnoreCase(listing.getCity())) {
return false;
}
PropertyType type = propertyType(search.getPropertyType());
if (type != null && listing.getPropertyType() != type) {
return false;
}
// Zapisane wyszukiwanie moze dotyczyc rodzaju nieruchomosci, ktorego serwis jeszcze
// nie obsluguje (np. dzialka) - wtedy zadne ogloszenie do niego nie pasuje.
if (type == null && notBlank(search.getPropertyType()) && !isAnyPropertyType(search.getPropertyType())) {
return false;
}
BigDecimal price = listing.getPrice();
if (price != null) {
if (search.getPriceMin() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMin())) < 0) {
return false;
}
if (search.getPriceMax() != null && price.compareTo(BigDecimal.valueOf(search.getPriceMax())) > 0) {
return false;
}
}
Double area = listing.getArea();
if (area != null) {
if (search.getAreaMin() != null && area < search.getAreaMin()) {
return false;
}
if (search.getAreaMax() != null && area > search.getAreaMax()) {
return false;
}
}
if (search.getRooms() != null && listing.getRooms() != null && listing.getRooms() < search.getRooms()) {
return false;
}
return marketMatches(search, listing);
}
/** Zaznaczenie obu rynkow (albo zadnego) znaczy "bez znaczenia". */
private boolean marketMatches(SavedSearch search, PropertyListing listing) {
boolean secondary = search.isSecondaryMarket();
boolean primary = search.isPrimaryMarket();
if (secondary == primary) {
return true;
}
String market = listing.getMarket();
if (market == null || market.isBlank()) {
return true;
}
String normalized = market.trim().toLowerCase(Locale.ROOT);
return secondary ? normalized.startsWith("wt") : normalized.startsWith("pierw");
}
private PropertyType propertyType(String label) {
if (label == null) {
return null;
}
String normalized = label.trim().toLowerCase(Locale.ROOT);
if (normalized.startsWith("mieszkan")) {
return PropertyType.APARTMENT;
}
if (normalized.startsWith("dom")) {
return PropertyType.HOUSE;
}
return null;
}
private boolean isAnyPropertyType(String label) {
String normalized = label.trim().toLowerCase(Locale.ROOT);
return normalized.startsWith("dowoln") || normalized.startsWith("wszystk");
}
private boolean notBlank(String value) {
return value != null && !value.isBlank();
}
private String price(PropertyListing listing) {
BigDecimal price = listing.getPrice();
if (price == null) {
return "cena do uzgodnienia";
}
return String.format(Locale.ROOT, "%,d zł", price.longValue()).replace(',', ' ');
}
}
@@ -0,0 +1,11 @@
package pl.polskalokalnie.search;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SavedSearchRepository extends JpaRepository<SavedSearch, Long> {
List<SavedSearch> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
void deleteByUserEmailIgnoreCase(String userEmail);
}
@@ -5,6 +5,7 @@ import jakarta.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Properties; import java.util.Properties;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -18,6 +19,14 @@ import pl.polskalokalnie.mailconfig.SmtpConfig;
@Component @Component
public class EmailSender { public class EmailSender {
/**
* Logo serwisu osadzone w samym mailu (obrazek inline). Szablon odwoluje sie do niego przez
* src="cid:...", wiec logo nie zalezy od pobierania z adresu serwisu - dziala tez w mailach
* ze srodowiska lokalnego i w programach, ktore blokuja obrazki z zewnatrz.
*/
public static final String LOGO_CID = "polskalokalnie-logo";
private static final String LOGO_RESOURCE = "mail/logo.png";
public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) { public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) {
if (config == null || config.getHost() == null || config.getHost().isBlank() if (config == null || config.getHost() == null || config.getHost().isBlank()
|| config.getUsername() == null || config.getUsername().isBlank()) { || config.getUsername() == null || config.getUsername().isBlank()) {
@@ -26,11 +35,7 @@ public class EmailSender {
try { try {
JavaMailSenderImpl mailSender = buildMailSender(config); JavaMailSenderImpl mailSender = buildMailSender(config);
MimeMessage message = mailSender.createMimeMessage(); MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); fillMessage(message, fromAddress(config), to, subject, htmlBody);
helper.setFrom(fromAddress(config));
helper.setTo(to);
helper.setSubject(subject == null ? "" : subject);
helper.setText(htmlBody, true);
mailSender.send(message); mailSender.send(message);
String messageId = message.getMessageID(); String messageId = message.getMessageID();
return SendResult.ok(messageId); return SendResult.ok(messageId);
@@ -39,6 +44,20 @@ public class EmailSender {
} }
} }
// Tresc HTML; gdy szablon uzywa logo (cid:), wiadomosc jest wieloczesciowa z logo jako zalacznikiem inline.
void fillMessage(MimeMessage message, InternetAddress from, String to, String subject, String htmlBody)
throws jakarta.mail.MessagingException {
boolean withLogo = htmlBody != null && htmlBody.contains("cid:" + LOGO_CID);
MimeMessageHelper helper = new MimeMessageHelper(message, withLogo, StandardCharsets.UTF_8.name());
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject == null ? "" : subject);
helper.setText(htmlBody, true);
if (withLogo) {
helper.addInline(LOGO_CID, new ClassPathResource(LOGO_RESOURCE), "image/png");
}
}
private JavaMailSenderImpl buildMailSender(SmtpConfig config) { private JavaMailSenderImpl buildMailSender(SmtpConfig config) {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(config.getHost()); mailSender.setHost(config.getHost());
@@ -23,8 +23,11 @@ public class MessageComposer {
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */ /** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
public String composeEmail(String rawBody, String recipientName, Long leadId) { public String composeEmail(String rawBody, String recipientName, Long leadId) {
String personalized = personalize(rawBody, recipientName); // O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized); // formularza, wiec do tresci HTML trafia escapowane - inaczej wstrzyknelo by link do maila.
String html = looksLikeHtml(rawBody)
? personalize(rawBody, recipientName == null ? null : escapeHtml(recipientName))
: textToHtml(personalize(rawBody, recipientName));
String link = unsubscribeLink(leadId, "email"); String link = unsubscribeLink(leadId, "email");
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">" String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">" + "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
@@ -64,6 +67,7 @@ public class MessageComposer {
return value return value
.replace("&", "&amp;") .replace("&", "&amp;")
.replace("<", "&lt;") .replace("<", "&lt;")
.replace(">", "&gt;"); .replace(">", "&gt;")
.replace("\"", "&quot;");
} }
} }
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.HtmlUtils;
/** /**
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej. * Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
@@ -20,17 +22,40 @@ public class UnsubscribeController {
this.unsubscribeService = unsubscribeService; this.unsubscribeService = unsubscribeService;
} }
/**
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
*/
@GetMapping(produces = MediaType.TEXT_HTML_VALUE) @GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) { public String confirm(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token); UnsubscribeService.Result result = unsubscribeService.preview(token);
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail"; String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
return page("Rezygnacja przyjęta", + "<button type=\"submit\">Wypisz mnie</button></form>";
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")") return page("Rezygnacja z wiadomości",
+ " został wypisany z wiadomości marketingowych (" + kanal + "). " "Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
} }
private static String page(String title, String message) { /**
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
*/
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
return page("Rezygnacja przyjęta",
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
}
private static String contact(UnsubscribeService.Result result) {
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
}
private static String channel(UnsubscribeService.Result result) {
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
}
private static String page(String title, String message, String action) {
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">" return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + title + "</title>" + "<title>" + title + "</title>"
@@ -38,7 +63,10 @@ public class UnsubscribeController {
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}" + "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;" + ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}" + "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>" + "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>"; + "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
+ "</div></body></html>";
} }
} }
@@ -41,28 +41,17 @@ public class UnsubscribeService {
return encodedPayload + "." + sign(encodedPayload); return encodedPayload + "." + sign(encodedPayload);
} }
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
public Result preview(String token) {
Target target = parse(token);
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
}
@Transactional @Transactional
public Result unsubscribe(String token) { public Result unsubscribe(String token) {
if (token == null || !token.contains(".")) { Target target = parse(token);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji"); Lead lead = target.lead();
} String channel = target.channel();
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
if (!sign(encodedPayload).equals(signature)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Long leadId = parseLeadId(parts[0]);
String channel = normalizeChannel(parts[1]);
Lead lead = leadRepository.findById(leadId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
if ("sms".equals(channel)) { if ("sms".equals(channel)) {
lead.setSmsOptOut(true); lead.setSmsOptOut(true);
@@ -76,6 +65,38 @@ public class UnsubscribeService {
return new Result(channel, maskContact(lead, channel)); return new Result(channel, maskContact(lead, channel));
} }
private record Target(Lead lead, String channel) {
}
private Target parse(String token) {
if (token == null || !token.contains(".")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
// ile poczatkowych znakow podpisu sie zgadza.
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload;
try {
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
return new Target(lead, normalizeChannel(parts[1]));
}
private String sign(String data) { private String sign(String data) {
try { try {
Mac mac = Mac.getInstance(HMAC_ALGO); Mac mac = Mac.getInstance(HMAC_ALGO);
@@ -0,0 +1,76 @@
package pl.polskalokalnie.user;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Usuniecie danych osobowych konta (art. 17 RODO). Dotad kasowany byl tylko wiersz app_users,
* ustawienia i ogloszenia - reszta zostawala w bazie jako sieroty: wiadomosci, powiadomienia,
* alerty, spotkania, tokeny, kody SMS z numerem telefonu i lead marketingowy.
*
* <p>Zostaja wylacznie dane, ktorych usuniecie byloby sprzeczne z innym obowiazkiem:
* zamowienia promowania (dokumentacja rozliczeniowa), zgloszenia ogloszen i uzytkownikow
* oraz korespondencja z obsluga (obrona przed roszczeniami i obowiazki moderacyjne).
* Te kategorie maja wlasne okresy przechowywania opisane w Polityce prywatnosci.</p>
*
* <p>Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie,
* tabela po tabeli - dzieki temu widac wprost, co znika.</p>
*/
@Service
public class AccountErasureService {
private static final Logger log = LoggerFactory.getLogger(AccountErasureService.class);
private final JdbcTemplate jdbcTemplate;
public AccountErasureService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void eraseFor(Long userId, String email) {
if (userId == null || email == null || email.isBlank()) {
return;
}
int removed = 0;
// Dane powiazane z identyfikatorem konta.
removed += update("DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?", userId, userId);
removed += update("DELETE FROM conversations WHERE buyer_id = ? OR seller_id = ?", userId, userId);
removed += update("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?", userId, userId);
removed += update("DELETE FROM meetings WHERE proposer_id = ? OR counterpart_id = ?", userId, userId);
// Dane powiazane z adresem e-mail.
removed += update("DELETE FROM listing_favorites WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM saved_searches WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM price_alert_events WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM price_alerts WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM notifications WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM auth_tokens WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM phone_otps WHERE lower(user_email) = lower(?)", email);
// Baza marketingowa: lead, jego tagi i pozycje w kolejkach kampanii.
removed += update("DELETE FROM campaign_recipients WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
removed += update("DELETE FROM lead_tags WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
removed += update("DELETE FROM leads WHERE lower(email) = lower(?)", email);
// Poradniki uzytkownika znikaja razem z kontem - tak samo jak jego ogloszenia.
removed += update("DELETE FROM article_attachments WHERE article_id IN (SELECT id FROM articles WHERE lower(author_email) = lower(?))", email);
removed += update("DELETE FROM articles WHERE lower(author_email) = lower(?)", email);
log.info("Usunieto {} rekordow powiazanych z kontem id={}", removed, userId);
}
private int update(String sql, Object... args) {
try {
return jdbcTemplate.update(sql, args);
} catch (RuntimeException ex) {
// Brak tabeli w starszej bazie nie moze zablokowac usuniecia konta.
log.warn("Nie udalo sie wykonac czyszczenia: {} ({})", sql, ex.getMessage());
return 0;
}
}
}
@@ -10,7 +10,6 @@ import jakarta.persistence.Id;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.ColumnDefault;
@Entity @Entity
@@ -52,6 +51,9 @@ public class AppUser {
@Column(length = 30) @Column(length = 30)
private String phone; private String phone;
// Adres dotyczy wylacznie kont firmowych (siedziba przedsiebiorcy - dana jawna z CEIDG/KRS).
// Konto prywatne go nie ma: do kontaktu wystarcza e-mail i telefon, a adres nieruchomosci
// stoi przy ogloszeniu. Minimalizacja danych, art. 5 ust. 1 lit. c RODO.
@Column(length = 255) @Column(length = 255)
private String address; private String address;
@@ -68,8 +70,6 @@ public class AppUser {
@Column(length = 20) @Column(length = 20)
private String nip; private String nip;
private LocalDate birthDate;
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu). // Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
@Column(nullable = false) @Column(nullable = false)
@ColumnDefault("0") @ColumnDefault("0")
@@ -95,11 +95,29 @@ public class AppUser {
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta. // odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt; private Instant passwordChangedAt;
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
private Instant sessionsValidFrom;
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
private Instant lastActiveAt;
/**
* Moment akceptacji Regulaminu i Polityki prywatnosci przy rejestracji. Zapisujemy go,
* zeby mozna bylo wykazac, kiedy uzytkownik zaakceptowal dokumenty (art. 7 ust. 1 RODO).
*/
@Column(name = "terms_accepted_at")
private Instant termsAcceptedAt;
@PrePersist @PrePersist
void onCreate() { void onCreate() {
if (createdAt == null) { if (createdAt == null) {
createdAt = Instant.now(); createdAt = Instant.now();
} }
if (lastActiveAt == null) {
lastActiveAt = createdAt;
}
} }
public Long getId() { public Long getId() {
@@ -202,14 +220,6 @@ public class AppUser {
this.nip = nip; this.nip = nip;
} }
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
public int getPromotionCredits() { public int getPromotionCredits() {
return promotionCredits; return promotionCredits;
} }
@@ -253,4 +263,29 @@ public class AppUser {
public void setPasswordChangedAt(Instant passwordChangedAt) { public void setPasswordChangedAt(Instant passwordChangedAt) {
this.passwordChangedAt = passwordChangedAt; this.passwordChangedAt = passwordChangedAt;
} }
public Instant getSessionsValidFrom() {
return sessionsValidFrom;
}
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
public void revokeSessions() {
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
}
public Instant getTermsAcceptedAt() {
return termsAcceptedAt;
}
public void setTermsAcceptedAt(Instant termsAcceptedAt) {
this.termsAcceptedAt = termsAcceptedAt;
}
public Instant getLastActiveAt() {
return lastActiveAt;
}
public void setLastActiveAt(Instant lastActiveAt) {
this.lastActiveAt = lastActiveAt;
}
} }
@@ -70,6 +70,9 @@ public class PublicProfileController {
? List.of() ? List.of()
: listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED); : listingRepository.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(user.getEmail(), ListingStatus.APPROVED);
// Telefon i adres z konta pokazujemy tylko, gdy wlasciciel wlaczyl publiczny kontakt.
boolean showContact = settings != null && settings.isPublicContactVisible();
return new PublicProfileResponse( return new PublicProfileResponse(
user.getId(), user.getId(),
user.getFullName(), user.getFullName(),
@@ -80,6 +83,15 @@ public class PublicProfileController {
user.getAccountType(), user.getAccountType(),
user.isVerified(), user.isVerified(),
user.getCreatedAt(), user.getCreatedAt(),
user.getLastActiveAt(),
settings == null ? null : settings.getCompanyTagline(),
settings == null ? null : settings.getWebsite(),
settings == null ? null : settings.getSocialLinkedin(),
settings == null ? null : settings.getSocialFacebook(),
settings == null ? null : settings.getSocialInstagram(),
settings == null ? null : settings.getSocialYoutube(),
showContact ? user.getPhone() : null,
showContact ? user.getAddress() : null,
listings.size(), listings.size(),
listings.stream() listings.stream()
.map(listing -> new PublicProfileResponse.PublicProfileListing( .map(listing -> new PublicProfileResponse.PublicProfileListing(

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